mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-31 00:04:44 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952bf6ebea | ||
|
|
85f4787eb9 |
@@ -826,26 +826,6 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
|
|||||||
helper.Success(c)
|
helper.Success(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags AI
|
|
||||||
// @Summary Switch to the bundled OpenClaw Feishu plugin
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body dto.AgentFeishuSwitchReq true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /ai/agents/channel/feishu/switch [post]
|
|
||||||
func (b *BaseApi) SwitchAgentFeishuPlugin(c *gin.Context) {
|
|
||||||
var req dto.AgentFeishuSwitchReq
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := agentService.SwitchFeishuPlugin(req); err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Get Agent Telegram channel config
|
// @Summary Get Agent Telegram channel config
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
+16
-12
@@ -72,6 +72,22 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, nil)
|
helper.SuccessWithData(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags AI
|
||||||
|
// @Summary Sync Ollama model list
|
||||||
|
// @Success 200 {array} dto.OllamaModelDropList
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security Timestamp
|
||||||
|
// @Router /ai/ollama/model/sync [post]
|
||||||
|
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
|
||||||
|
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
|
||||||
|
list, err := aiToolService.Sync()
|
||||||
|
if err != nil {
|
||||||
|
helper.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, list)
|
||||||
|
}
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Page Ollama models
|
// @Summary Page Ollama models
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -98,18 +114,6 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
|
|
||||||
var req dto.OllamaModelAccountSync
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := aiToolService.SyncAccount(req); err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Page Ollama models
|
// @Summary Page Ollama models
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (b *BaseApi) AIBenchmarkSources(c *gin.Context) {
|
|
||||||
sources, err := service.AIBenchmarkSources(c.Query("kind"), c.Query("selected"))
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, sources)
|
|
||||||
}
|
|
||||||
@@ -207,7 +207,6 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Param key path string true "app key"
|
// @Param key path string true "app key"
|
||||||
// @Success 200 {file} file "app icon"
|
// @Success 200 {file} file "app icon"
|
||||||
// @Success 302 "redirect to the official icon URL"
|
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /apps/icon/:key [get]
|
// @Router /apps/icon/:key [get]
|
||||||
@@ -217,18 +216,12 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
|||||||
helper.BadRequest(c, err)
|
helper.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
iconBytes, source, etag, err := appService.GetAppIcon(appKey)
|
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if appicon.IsRemoteURL(source) {
|
|
||||||
c.Header("Cache-Control", "no-cache")
|
|
||||||
c.Redirect(http.StatusFound, source)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(iconBytes) == 0 {
|
if len(iconBytes) == 0 {
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -236,7 +229,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
|||||||
|
|
||||||
c.Header("Cache-Control", "public, max-age=2592000")
|
c.Header("Cache-Control", "public, max-age=2592000")
|
||||||
|
|
||||||
if etag != "" && source != "" {
|
if etag != "" && filename != "" {
|
||||||
c.Header("ETag", etag)
|
c.Header("ETag", etag)
|
||||||
if c.GetHeader("If-None-Match") == etag {
|
if c.GetHeader("If-None-Match") == etag {
|
||||||
c.Status(http.StatusNotModified)
|
c.Status(http.StatusNotModified)
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAppIconEndpointPreservesRemoteAndHistoricalSources(t *testing.T) {
|
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "icons.db")), &gorm.Config{
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
|
|
||||||
global.DB = db
|
|
||||||
global.Dir.IconCacheDir = t.TempDir()
|
|
||||||
global.LOG = logrus.New()
|
|
||||||
global.LOG.SetOutput(io.Discard)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
|
|
||||||
sqlDB, _ := db.DB()
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
var imageRequests atomic.Int32
|
|
||||||
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
imageRequests.Add(1)
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
t.Cleanup(cdn.Close)
|
|
||||||
remoteURL := cdn.URL + "/icon.png?token=a%2Bb&v=42#image"
|
|
||||||
image := "\x89PNG\r\n\x1a\noriginal-icon"
|
|
||||||
fileName := "app_php.png"
|
|
||||||
if err := os.WriteFile(filepath.Join(global.Dir.IconCacheDir, fileName), []byte(image), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app := model.App{Key: "php", Resource: constant.AppResourceRemote}
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
router := gin.New()
|
|
||||||
router.GET("/api/v2/apps/icon/:key", (&BaseApi{}).GetAppIcon)
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
icon string
|
|
||||||
ifNoneMatch string
|
|
||||||
status int
|
|
||||||
body string
|
|
||||||
etag string
|
|
||||||
}{
|
|
||||||
{"remote URL", remoteURL, "", http.StatusFound, "", ""},
|
|
||||||
{"remote URL ignores legacy etag", remoteURL, `"old"`, http.StatusFound, "", ""},
|
|
||||||
{"historical file", appicon.BuildIconField(fileName, `"42"`), "", http.StatusOK, image, `"42"`},
|
|
||||||
{"historical file cached", appicon.BuildIconField(fileName, `"42"`), `"42"`, http.StatusNotModified, "", `"42"`},
|
|
||||||
{"historical file changed", appicon.BuildIconField(fileName, `"42"`), `"old"`, http.StatusOK, image, `"42"`},
|
|
||||||
{"historical base64", base64.StdEncoding.EncodeToString([]byte(image)), "", http.StatusOK, image, ""},
|
|
||||||
{"missing file", "app_missing.png?etag=old", "", http.StatusNoContent, "", ""},
|
|
||||||
{"invalid base64", "not-an-icon", "", http.StatusNoContent, "", ""},
|
|
||||||
{"invalid URL", "javascript:alert(1)", "", http.StatusNoContent, "", ""},
|
|
||||||
{"empty icon", "", "", http.StatusNoContent, "", ""},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if err := db.Model(&app).UpdateColumn("icon", tc.icon).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php?operateNode=child", nil)
|
|
||||||
req.Header.Set("If-None-Match", tc.ifNoneMatch)
|
|
||||||
res := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(res, req)
|
|
||||||
if res.Code != tc.status {
|
|
||||||
t.Fatalf("status = %d, want %d; body = %s", res.Code, tc.status, res.Body.String())
|
|
||||||
}
|
|
||||||
if res.Header().Get("ETag") != tc.etag {
|
|
||||||
t.Fatalf("etag = %q, want %q", res.Header().Get("ETag"), tc.etag)
|
|
||||||
}
|
|
||||||
if tc.status == http.StatusFound {
|
|
||||||
if res.Header().Get("Location") != remoteURL || res.Header().Get("Cache-Control") != "no-cache" {
|
|
||||||
t.Fatalf("redirect URL or cache policy was changed: %v", res.Header())
|
|
||||||
}
|
|
||||||
} else if res.Body.String() != tc.body {
|
|
||||||
t.Fatalf("body = %q, want %q", res.Body.String(), tc.body)
|
|
||||||
}
|
|
||||||
if imageRequests.Load() != 0 {
|
|
||||||
t.Fatalf("icon endpoint fetched the image %d times", imageRequests.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -203,7 +203,6 @@ func (b *BaseApi) GetServices(c *gin.Context) {
|
|||||||
// @Tags App
|
// @Tags App
|
||||||
// @Summary Search app update version by install id
|
// @Summary Search app update version by install id
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Param request body request.AppUpdateVersion true "request"
|
|
||||||
// @Success 200 {array} dto.AppVersion
|
// @Success 200 {array} dto.AppVersion
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
total, list, err := containerService.Page(c.Request.Context(), req)
|
total, list, err := containerService.Page(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -249,10 +249,9 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.ContainerStatus
|
// @Success 200 {object} dto.ContainerStatus
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Param containersOnly query boolean false "Only count containers"
|
|
||||||
// @Router /containers/status [get]
|
// @Router /containers/status [get]
|
||||||
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
|
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
|
||||||
data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
|
data, err := containerService.LoadStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -416,14 +415,9 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
|
|||||||
// @Success 200 {array} dto.ContainerListStats
|
// @Success 200 {array} dto.ContainerListStats
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
|
|
||||||
// @Router /containers/list/stats [get]
|
// @Router /containers/list/stats [get]
|
||||||
func (b *BaseApi) ContainerListStats(c *gin.Context) {
|
func (b *BaseApi) ContainerListStats(c *gin.Context) {
|
||||||
var ids []string
|
data, err := containerService.ContainerListStats()
|
||||||
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
|
|
||||||
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
|
|
||||||
}
|
|
||||||
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
req.Password = string(password)
|
req.Password = string(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := mysqlService.Create(context.Background(), req); err != nil {
|
if _, err := mysqlService.Create(context.Background(), req); err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,16 +3,11 @@ package v2
|
|||||||
import (
|
import (
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
|
|
||||||
helper.SuccessWithData(c, service.LoadModelGPUDevices())
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Load gpu / xpu info
|
// @Summary Load gpu / xpu info
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
@@ -23,7 +22,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
||||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
|
||||||
res := dto.Response{
|
res := dto.Response{
|
||||||
Code: code,
|
Code: code,
|
||||||
Message: "",
|
Message: "",
|
||||||
@@ -34,7 +32,6 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
|
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
|
||||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
|
||||||
res := dto.Response{
|
res := dto.Response{
|
||||||
Code: code,
|
Code: code,
|
||||||
ErrorCode: businessCode,
|
ErrorCode: businessCode,
|
||||||
@@ -45,7 +42,6 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
|
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
|
||||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
|
||||||
res := dto.Response{
|
res := dto.Response{
|
||||||
Code: code,
|
Code: code,
|
||||||
Data: data,
|
Data: data,
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mcpBusinessAPI struct {
|
|
||||||
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MCPBusinessDispatch() gin.HandlerFunc {
|
|
||||||
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
var handler gin.HandlerFunc
|
|
||||||
switch c.Request.Method + " " + c.FullPath() {
|
|
||||||
case "POST /api/v2/databases":
|
|
||||||
handler = api.createMysql
|
|
||||||
case "POST /api/v2/databases/status":
|
|
||||||
handler = api.mysqlStatus
|
|
||||||
case "GET /api/v2/databases/db/list/:type":
|
|
||||||
handler = api.databases
|
|
||||||
case "POST /api/v2/apps/install":
|
|
||||||
handler = api.install
|
|
||||||
case "GET /api/v2/apps/installed/info/:appInstallId":
|
|
||||||
handler = api.appInfo
|
|
||||||
default:
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
present, trusted := mcpapi.Check(c.Request)
|
|
||||||
if !present {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !trusted {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler(c)
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
|
|
||||||
var req dto.MysqlDBCreate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Password) != 0 {
|
|
||||||
password, err := base64.StdEncoding.DecodeString(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Password = string(password)
|
|
||||||
}
|
|
||||||
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nodeID, _ := mcpapi.NodeID(c.Request)
|
|
||||||
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
|
|
||||||
var req dto.OperationWithNameAndType
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) databases(c *gin.Context) {
|
|
||||||
dbType, err := helper.GetStrParamByKey(c, "type")
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) install(c *gin.Context) {
|
|
||||||
var req request.AppInstallCreate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if value, exists := req.Params["WEBSITE_DIR"]; exists {
|
|
||||||
directory, ok := value.(string)
|
|
||||||
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
installed, err := api.installApp(req, true)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
|
|
||||||
id, err := helper.GetIntParamByKey(c, "appInstallId")
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, info)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PanelMCPTaskAccess() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if hasPanelMCPTaskAccess(c.Request) {
|
|
||||||
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasPanelMCPTaskAccess(req *http.Request) bool {
|
|
||||||
if !global.IsMaster {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
|
||||||
values := req.Header.Values("X-Panel-MCP-Task-Access")
|
|
||||||
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Summary List quick directories
|
|
||||||
// @Success 200 {array} model.QuickDirectory
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /dashboard/quick/directory [get]
|
|
||||||
func (b *BaseApi) ListQuickDirectories(c *gin.Context) {
|
|
||||||
items, err := dashboardService.ListQuickDirectories()
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Summary Create quick directory
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body dto.QuickDirectoryCreate true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /dashboard/quick/directory [post]
|
|
||||||
func (b *BaseApi) CreateQuickDirectory(c *gin.Context) {
|
|
||||||
var req dto.QuickDirectoryCreate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := dashboardService.CreateQuickDirectory(req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Summary Update quick directory
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body dto.QuickDirectoryUpdate true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /dashboard/quick/directory/update [post]
|
|
||||||
func (b *BaseApi) UpdateQuickDirectory(c *gin.Context) {
|
|
||||||
var req dto.QuickDirectoryUpdate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := dashboardService.UpdateQuickDirectory(req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Summary Delete quick directory
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body dto.QuickDirectoryDelete true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /dashboard/quick/directory/del [post]
|
|
||||||
func (b *BaseApi) DeleteQuickDirectory(c *gin.Context) {
|
|
||||||
var req dto.QuickDirectoryDelete
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dashboardService.DeleteQuickDirectory(req); err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
@@ -61,14 +61,13 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
|
|||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /runtimes/del [post]
|
// @Router /runtimes/del [post]
|
||||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
|
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"}
|
||||||
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
|
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
|
||||||
var req request.RuntimeDelete
|
var req request.RuntimeDelete
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name, err := runtimeService.Delete(req)
|
err := runtimeService.Delete(req)
|
||||||
helper.SetOperationLogValues(c, map[string]string{"name": name})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
|
|||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, list, err := taskServiceForRequest(c).Page(req)
|
total, list, err := taskService.Page(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -47,7 +45,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var res *response.FileLineContent
|
var res *response.FileLineContent
|
||||||
res, err := taskServiceForRequest(c).ReadByLine(req)
|
res, err := taskService.ReadByLine(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -66,17 +64,10 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
|
|||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /logs/tasks/executing/count [get]
|
// @Router /logs/tasks/executing/count [get]
|
||||||
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
|
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
|
||||||
count, err := taskServiceForRequest(c).CountExecutingTask()
|
count, err := taskService.CountExecutingTask()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
helper.SuccessWithData(c, count)
|
helper.SuccessWithData(c, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
|
|
||||||
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
|
|
||||||
return service.NewITaskService(granted...)
|
|
||||||
}
|
|
||||||
return taskService
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
|
|||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /websites/php/version [post]
|
// @Router /websites/php/version [post]
|
||||||
// @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
|
// @x-panel-log {"bodyKeys":["websiteId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteId","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
|
||||||
func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
|
func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
|
||||||
var req request.WebsitePHPVersionReq
|
var req request.WebsitePHPVersionReq
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,64 +176,3 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
|
|||||||
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
|
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
|
||||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Website CA
|
|
||||||
// @Summary Upload website CA archive
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "ZIP containing ca.crt and ca.key"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /websites/ca/upload [post]
|
|
||||||
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
|
|
||||||
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
|
|
||||||
helper.InternalServer(c, buserr.New("ErrCAUpload"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Request.MultipartForm != nil {
|
|
||||||
defer c.Request.MultipartForm.RemoveAll()
|
|
||||||
}
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
|
|
||||||
helper.InternalServer(c, buserr.New("ErrCAUpload"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stream, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags Website CA
|
|
||||||
// @Summary Import website CA from PEM or server files
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body request.WebsiteCAImport true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /websites/ca/import [post]
|
|
||||||
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
|
|
||||||
var req request.WebsiteCAImport
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := websiteCAService.Import(req); err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
|
|||||||
+36
-63
@@ -9,8 +9,7 @@ type AgentCreateReq struct {
|
|||||||
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
|
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
|
||||||
BridgePort int `json:"bridgePort"`
|
BridgePort int `json:"bridgePort"`
|
||||||
AllowedOrigins []string `json:"allowedOrigins"`
|
AllowedOrigins []string `json:"allowedOrigins"`
|
||||||
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
|
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
|
||||||
AccessHost string `json:"accessHost"`
|
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
AccountID uint `json:"accountId"`
|
AccountID uint `json:"accountId"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -111,7 +110,6 @@ type AgentBatchOperateResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentItem struct {
|
type AgentItem struct {
|
||||||
AccessHost string `json:"accessHost"`
|
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
@@ -318,7 +316,6 @@ type AgentAccountModelDiscoverReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountModelCreateReq struct {
|
type AgentAccountModelCreateReq struct {
|
||||||
TaskID string `json:"taskID"`
|
|
||||||
AccountID uint `json:"accountId" validate:"required"`
|
AccountID uint `json:"accountId" validate:"required"`
|
||||||
Model AgentAccountModel `json:"model" validate:"required"`
|
Model AgentAccountModel `json:"model" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -329,9 +326,8 @@ type AgentAccountModelUpdateReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountModelDeleteReq struct {
|
type AgentAccountModelDeleteReq struct {
|
||||||
DeleteFiles bool `json:"deleteFiles"`
|
AccountID uint `json:"accountId" validate:"required"`
|
||||||
AccountID uint `json:"accountId" validate:"required"`
|
RecordID uint `json:"recordId" validate:"required"`
|
||||||
RecordID uint `json:"recordId" validate:"required"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountCreateReq struct {
|
type AgentAccountCreateReq struct {
|
||||||
@@ -349,18 +345,17 @@ type AgentAccountCreateReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountUpdateReq struct {
|
type AgentAccountUpdateReq struct {
|
||||||
ID uint `json:"id" validate:"required"`
|
ID uint `json:"id" validate:"required"`
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
APIKey string `json:"apiKey" validate:"required"`
|
APIKey string `json:"apiKey" validate:"required"`
|
||||||
RememberAPIKey bool `json:"rememberApiKey"`
|
RememberAPIKey bool `json:"rememberApiKey"`
|
||||||
BaseURL string `json:"baseURL"`
|
BaseURL string `json:"baseURL"`
|
||||||
APIType string `json:"apiType" validate:"required"`
|
APIType string `json:"apiType" validate:"required"`
|
||||||
AuthMode string `json:"authMode"`
|
AuthMode string `json:"authMode"`
|
||||||
VerifyModel string `json:"verifyModel"`
|
VerifyModel string `json:"verifyModel"`
|
||||||
ValidateAvailability *bool `json:"validateAvailability"`
|
ValidateAvailability *bool `json:"validateAvailability"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
SyncAgents bool `json:"syncAgents"`
|
SyncAgents bool `json:"syncAgents"`
|
||||||
Models []AgentAccountModel `json:"models"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountVerifyReq struct {
|
type AgentAccountVerifyReq struct {
|
||||||
@@ -389,7 +384,6 @@ type AgentAccountProviderCountReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentAccountInfo struct {
|
type AgentAccountInfo struct {
|
||||||
AppInstallID uint `json:"appInstallId"`
|
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
MasterAccountID uint `json:"masterAccountId"`
|
MasterAccountID uint `json:"masterAccountId"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
@@ -436,53 +430,32 @@ type AgentFeishuConfigReq struct {
|
|||||||
AgentID uint `json:"agentId" validate:"required"`
|
AgentID uint `json:"agentId" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentFeishuTransition struct {
|
|
||||||
Confirmed bool `json:"confirmed"`
|
|
||||||
Streaming bool `json:"streaming"`
|
|
||||||
GroupSessionScope string `json:"groupSessionScope"`
|
|
||||||
RequireMention string `json:"requireMention"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentFeishuSwitchReq struct {
|
|
||||||
AgentID uint `json:"agentId" validate:"required"`
|
|
||||||
TaskID string `json:"taskID" validate:"required"`
|
|
||||||
Streaming bool `json:"streaming"`
|
|
||||||
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender group_topic group_topic_sender"`
|
|
||||||
RequireMention string `json:"requireMention" validate:"required,oneof=true false"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentFeishuConfigUpdateReq struct {
|
type AgentFeishuConfigUpdateReq struct {
|
||||||
GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
|
AgentID uint `json:"agentId" validate:"required"`
|
||||||
AgentID uint `json:"agentId" validate:"required"`
|
Enabled bool `json:"enabled"`
|
||||||
Enabled bool `json:"enabled"`
|
ThreadSession bool `json:"threadSession"`
|
||||||
ThreadSession bool `json:"threadSession"`
|
ReplyMode string `json:"replyMode" validate:"required"`
|
||||||
ReplyMode string `json:"replyMode" validate:"required"`
|
Streaming bool `json:"streaming"`
|
||||||
Streaming bool `json:"streaming"`
|
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
|
||||||
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
|
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
|
||||||
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
|
GroupAllowFrom []string `json:"groupAllowFrom"`
|
||||||
GroupAllowFrom []string `json:"groupAllowFrom"`
|
Domain string `json:"domain"`
|
||||||
Domain string `json:"domain"`
|
ConnectionMode string `json:"connectionMode"`
|
||||||
ConnectionMode string `json:"connectionMode"`
|
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
|
||||||
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentFeishuConfig struct {
|
type AgentFeishuConfig struct {
|
||||||
BuiltinManaged bool `json:"builtinManaged"`
|
Enabled bool `json:"enabled"`
|
||||||
LegacyConfigured bool `json:"legacyConfigured"`
|
ThreadSession bool `json:"threadSession"`
|
||||||
GroupSessionScope string `json:"groupSessionScope"`
|
ReplyMode string `json:"replyMode"`
|
||||||
BuiltinAvailable bool `json:"builtinAvailable"`
|
Streaming bool `json:"streaming"`
|
||||||
Legacy bool `json:"legacy"`
|
RequireMention string `json:"requireMention"`
|
||||||
Enabled bool `json:"enabled"`
|
GroupPolicy string `json:"groupPolicy"`
|
||||||
ThreadSession bool `json:"threadSession"`
|
GroupAllowFrom []string `json:"groupAllowFrom"`
|
||||||
ReplyMode string `json:"replyMode"`
|
Domain string `json:"domain"`
|
||||||
Streaming bool `json:"streaming"`
|
ConnectionMode string `json:"connectionMode"`
|
||||||
RequireMention string `json:"requireMention"`
|
Bots []AgentFeishuBot `json:"bots"`
|
||||||
GroupPolicy string `json:"groupPolicy"`
|
Installed bool `json:"installed"`
|
||||||
GroupAllowFrom []string `json:"groupAllowFrom"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
ConnectionMode string `json:"connectionMode"`
|
|
||||||
Bots []AgentFeishuBot `json:"bots"`
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentTelegramConfigReq struct {
|
type AgentTelegramConfigReq struct {
|
||||||
|
|||||||
+5
-28
@@ -2,28 +2,6 @@ package dto
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type ModelGPUDevice struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
UUID string `json:"uuid,omitempty"`
|
|
||||||
Available bool `json:"available"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelGPUDevices struct {
|
|
||||||
Devices []ModelGPUDevice `json:"devices"`
|
|
||||||
Warnings []string `json:"warnings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelGPUConfig struct {
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
Count int `json:"count,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
DeviceIDs []string `json:"deviceIDs"`
|
|
||||||
Capabilities []string `json:"capabilities"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OllamaModelInfo struct {
|
type OllamaModelInfo struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -36,17 +14,16 @@ type OllamaModelInfo struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OllamaModelDropList struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type OllamaModelName struct {
|
type OllamaModelName struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TaskID string `json:"taskID"`
|
TaskID string `json:"taskID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaModelAccountSync struct {
|
|
||||||
ID uint `json:"id" validate:"required"`
|
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
|
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OllamaBindDomain struct {
|
type OllamaBindDomain struct {
|
||||||
Domain string `json:"domain" validate:"required"`
|
Domain string `json:"domain" validate:"required"`
|
||||||
AppInstallID uint `json:"appInstallID" validate:"required"`
|
AppInstallID uint `json:"appInstallID" validate:"required"`
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
type AIBenchmarkSource struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
BaseURL string `json:"baseUrl"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
Models []string `json:"models"`
|
|
||||||
Tokenizer string `json:"tokenizer"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
@@ -43,11 +43,9 @@ type AppOssConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppVersion struct {
|
type AppVersion struct {
|
||||||
FeishuLegacy bool `json:"feishuLegacy,omitempty"`
|
Version string `json:"version"`
|
||||||
FeishuConfigured bool `json:"feishuConfigured,omitempty"`
|
DetailId uint `json:"detailId"`
|
||||||
Version string `json:"version"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DetailId uint `json:"detailId"`
|
|
||||||
DockerCompose string `json:"dockerCompose"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppList struct {
|
type AppList struct {
|
||||||
|
|||||||
@@ -171,8 +171,7 @@ type ContainerItemStats struct {
|
|||||||
BuildCacheReclaimable int64 `json:"buildCacheReclaimable"`
|
BuildCacheReclaimable int64 `json:"buildCacheReclaimable"`
|
||||||
}
|
}
|
||||||
type ContainerListStats struct {
|
type ContainerListStats struct {
|
||||||
ContainerID string `json:"containerID"`
|
ContainerID string `json:"containerID"`
|
||||||
ContainerName string `json:"containerName"`
|
|
||||||
|
|
||||||
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
|
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
|
||||||
SystemUsage uint64 `json:"systemUsage"`
|
SystemUsage uint64 `json:"systemUsage"`
|
||||||
|
|||||||
@@ -31,27 +31,7 @@ type DashboardBase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChangeQuicks struct {
|
type ChangeQuicks struct {
|
||||||
Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
|
Quicks []QuickJump `json:"quicks"`
|
||||||
}
|
|
||||||
|
|
||||||
type QuickVisibility struct {
|
|
||||||
ID uint `json:"id" validate:"required"`
|
|
||||||
IsShow bool `json:"isShow"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuickDirectoryCreate struct {
|
|
||||||
Path string `json:"path" validate:"required"`
|
|
||||||
Alias string `json:"alias"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuickDirectoryDelete struct {
|
|
||||||
ID uint `json:"id" validate:"required"`
|
|
||||||
Legacy bool `json:"legacy"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuickDirectoryUpdate struct {
|
|
||||||
QuickDirectoryDelete
|
|
||||||
QuickDirectoryCreate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuickJump struct {
|
type QuickJump struct {
|
||||||
@@ -177,7 +157,6 @@ type DiskInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GPUInfo struct {
|
type GPUInfo struct {
|
||||||
SharedMemory bool `json:"sharedMemory"`
|
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Index uint `json:"index"`
|
Index uint `json:"index"`
|
||||||
NPUIndex uint `json:"npuIndex"`
|
NPUIndex uint `json:"npuIndex"`
|
||||||
|
|||||||
+11
-12
@@ -56,18 +56,17 @@ type MonitorGPUOptions struct {
|
|||||||
Options []string `json:"options"`
|
Options []string `json:"options"`
|
||||||
}
|
}
|
||||||
type GPUChartHide struct {
|
type GPUChartHide struct {
|
||||||
SharedMemory bool `json:"sharedMemory"`
|
DeviceID string `json:"deviceID"`
|
||||||
DeviceID string `json:"deviceID"`
|
Legacy bool `json:"legacy"`
|
||||||
Legacy bool `json:"legacy"`
|
ProductName string `json:"productName"`
|
||||||
ProductName string `json:"productName"`
|
Type string `json:"type"`
|
||||||
Type string `json:"type"`
|
Process bool `json:"process"`
|
||||||
Process bool `json:"process"`
|
GPU bool `json:"gpu"`
|
||||||
GPU bool `json:"gpu"`
|
Memory bool `json:"memory"`
|
||||||
Memory bool `json:"memory"`
|
Power bool `json:"power"`
|
||||||
Power bool `json:"power"`
|
PowerLimit bool `json:"powerLimit"`
|
||||||
PowerLimit bool `json:"powerLimit"`
|
Temperature bool `json:"temperature"`
|
||||||
Temperature bool `json:"temperature"`
|
Speed bool `json:"speed"`
|
||||||
Speed bool `json:"speed"`
|
|
||||||
}
|
}
|
||||||
type MonitorGPUSearch struct {
|
type MonitorGPUSearch struct {
|
||||||
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
|
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
|
||||||
|
|||||||
@@ -22,13 +22,6 @@ type AppInstallCreate struct {
|
|||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Services map[string]string `json:"services"`
|
Services map[string]string `json:"services"`
|
||||||
TaskID string `json:"taskID"`
|
TaskID string `json:"taskID"`
|
||||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
|
||||||
|
|
||||||
GPUImageType string `json:"-"`
|
|
||||||
|
|
||||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
|
||||||
|
|
||||||
AppContainerConfig
|
AppContainerConfig
|
||||||
NodePushConfig
|
NodePushConfig
|
||||||
@@ -90,33 +83,31 @@ type AppBackupDelete struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppInstalledOperate struct {
|
type AppInstalledOperate struct {
|
||||||
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
|
InstallId uint `json:"installId" validate:"required"`
|
||||||
InstallId uint `json:"installId" validate:"required"`
|
BackupId uint `json:"backupId"`
|
||||||
BackupId uint `json:"backupId"`
|
DetailId uint `json:"detailId"`
|
||||||
DetailId uint `json:"detailId"`
|
Operate constant.AppOperate `json:"operate" validate:"required"`
|
||||||
Operate constant.AppOperate `json:"operate" validate:"required"`
|
ForceDelete bool `json:"forceDelete"`
|
||||||
ForceDelete bool `json:"forceDelete"`
|
DeleteBackup bool `json:"deleteBackup"`
|
||||||
DeleteBackup bool `json:"deleteBackup"`
|
DeleteDB bool `json:"deleteDB"`
|
||||||
DeleteDB bool `json:"deleteDB"`
|
Backup bool `json:"backup"`
|
||||||
Backup bool `json:"backup"`
|
PullImage bool `json:"pullImage"`
|
||||||
PullImage bool `json:"pullImage"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DockerCompose string `json:"dockerCompose"`
|
TaskID string `json:"taskID"`
|
||||||
TaskID string `json:"taskID"`
|
DeleteImage bool `json:"deleteImage"`
|
||||||
DeleteImage bool `json:"deleteImage"`
|
Favorite bool `json:"favorite"`
|
||||||
Favorite bool `json:"favorite"`
|
|
||||||
|
|
||||||
UseLifecycleScripts bool `json:"-"`
|
UseLifecycleScripts bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallUpgrade struct {
|
type AppInstallUpgrade struct {
|
||||||
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
|
InstallID uint `json:"installId"`
|
||||||
InstallID uint `json:"installId"`
|
DetailID uint `json:"detailId"`
|
||||||
DetailID uint `json:"detailId"`
|
Backup bool `json:"backup"`
|
||||||
Backup bool `json:"backup"`
|
PullImage bool `json:"pullImage"`
|
||||||
PullImage bool `json:"pullImage"`
|
DeleteImage bool `json:"deleteImage"`
|
||||||
DeleteImage bool `json:"deleteImage"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DockerCompose string `json:"dockerCompose"`
|
TaskID string `json:"taskID"`
|
||||||
TaskID string `json:"taskID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallDelete struct {
|
type AppInstallDelete struct {
|
||||||
@@ -131,13 +122,9 @@ type AppInstallDelete struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppInstalledUpdate struct {
|
type AppInstalledUpdate struct {
|
||||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
InstallId uint `json:"installId" validate:"required"`
|
||||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
Params map[string]interface{} `json:"params" validate:"required"`
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
TaskID string `json:"-"`
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
|
||||||
InstallId uint `json:"installId" validate:"required"`
|
|
||||||
Params map[string]interface{} `json:"params" validate:"required"`
|
|
||||||
TaskID string `json:"-"`
|
|
||||||
AppContainerConfig
|
AppContainerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +147,6 @@ type PortUpdate struct {
|
|||||||
type AppUpdateVersion struct {
|
type AppUpdateVersion struct {
|
||||||
AppInstallID uint `json:"appInstallID" validate:"required"`
|
AppInstallID uint `json:"appInstallID" validate:"required"`
|
||||||
UpdateVersion string `json:"updateVersion"`
|
UpdateVersion string `json:"updateVersion"`
|
||||||
LoadCompose *bool `json:"loadCompose,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallSortItem struct {
|
type AppInstallSortItem struct {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package request
|
|
||||||
|
|
||||||
type MCPWebsiteBinding struct {
|
|
||||||
Operation string `json:"operation"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
Upstream string `json:"upstream"`
|
|
||||||
CoreHost string `json:"coreHost"`
|
|
||||||
ProxyKey string `json:"proxyKey"`
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,14 @@ type TensorRTLLMSearch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TensorRTLLMCreate struct {
|
type TensorRTLLMCreate struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
ContainerName string `json:"containerName" validate:"required"`
|
ContainerName string `json:"containerName" validate:"required"`
|
||||||
Version string `json:"version" validate:"required"`
|
Version string `json:"version" validate:"required"`
|
||||||
ModelDir string `json:"modelDir" validate:"required"`
|
ModelDir string `json:"modelDir" validate:"required"`
|
||||||
Image string `json:"image" validate:"required"`
|
Image string `json:"image" validate:"required"`
|
||||||
Command string `json:"command" validate:"required"`
|
Command string `json:"command" validate:"required"`
|
||||||
ModelType string `json:"modelType"`
|
ModelType string `json:"modelType"`
|
||||||
ModelSpeedup bool `json:"modelSpeedup"`
|
ModelSpeedup bool `json:"modelSpeedup"`
|
||||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
|
||||||
DockerConfig
|
DockerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,12 +182,3 @@ type WebsiteSSLFileUpload struct {
|
|||||||
PushNode bool `json:"pushNode"`
|
PushNode bool `json:"pushNode"`
|
||||||
Nodes string `json:"nodes"`
|
Nodes string `json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebsiteCAImport struct {
|
|
||||||
Name string `json:"name" validate:"required,max=128"`
|
|
||||||
Type string `json:"type" validate:"required,oneof=paste local"`
|
|
||||||
Certificate string `json:"certificate" validate:"required_if=Type paste"`
|
|
||||||
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
|
|
||||||
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
|
|
||||||
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type AppDTO struct {
|
|||||||
type AppItem struct {
|
type AppItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Icon string `json:"icon,omitempty"`
|
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -51,21 +50,19 @@ type TagDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppInstalledCheck struct {
|
type AppInstalledCheck struct {
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
IsExist bool `json:"isExist"`
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
Name string `json:"name"`
|
||||||
IsExist bool `json:"isExist"`
|
App string `json:"app"`
|
||||||
Name string `json:"name"`
|
Version string `json:"version"`
|
||||||
App string `json:"app"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Status string `json:"status"`
|
LastBackupAt string `json:"lastBackupAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
AppInstallID uint `json:"appInstallId"`
|
||||||
LastBackupAt string `json:"lastBackupAt"`
|
ContainerName string `json:"containerName"`
|
||||||
AppInstallID uint `json:"appInstallId"`
|
InstallPath string `json:"installPath"`
|
||||||
ContainerName string `json:"containerName"`
|
HttpPort int `json:"httpPort"`
|
||||||
InstallPath string `json:"installPath"`
|
HttpsPort int `json:"httpsPort"`
|
||||||
HttpPort int `json:"httpPort"`
|
WebsiteDir string `json:"websiteDir"`
|
||||||
HttpsPort int `json:"httpsPort"`
|
|
||||||
WebsiteDir string `json:"websiteDir"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppDetailDTO struct {
|
type AppDetailDTO struct {
|
||||||
@@ -187,15 +184,7 @@ type AppParam struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
|
Params []AppParam `json:"params"`
|
||||||
AppKey string `json:"appKey"`
|
RawCompose string `json:"rawCompose"`
|
||||||
Name string `json:"name"`
|
|
||||||
HttpPort int `json:"httpPort"`
|
|
||||||
SyncModelAccount bool `json:"syncModelAccount"`
|
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
|
||||||
Params []AppParam `json:"params"`
|
|
||||||
RawCompose string `json:"rawCompose"`
|
|
||||||
IsEdit bool `json:"isEdit"`
|
|
||||||
request.AppContainerConfig
|
request.AppContainerConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package response
|
|
||||||
|
|
||||||
type MCPWebsiteBinding struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
WebsiteStatus string `json:"websiteStatus"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Definitive bool `json:"definitive,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
)
|
)
|
||||||
@@ -13,13 +12,12 @@ type TensorRTLLMsRes struct {
|
|||||||
|
|
||||||
type TensorRTLLMDTO struct {
|
type TensorRTLLMDTO struct {
|
||||||
model.TensorRTLLM
|
model.TensorRTLLM
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Dir string `json:"dir"`
|
Dir string `json:"dir"`
|
||||||
ModelDir string `json:"modelDir"`
|
ModelDir string `json:"modelDir"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
|
||||||
|
|
||||||
ExposedPorts []request.ExposedPort `json:"exposedPorts"`
|
ExposedPorts []request.ExposedPort `json:"exposedPorts"`
|
||||||
Environments []request.Environment `json:"environments"`
|
Environments []request.Environment `json:"environments"`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package model
|
|||||||
|
|
||||||
type AgentAccount struct {
|
type AgentAccount struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
AppInstallID uint `json:"appInstallId"`
|
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ type AppInstall struct {
|
|||||||
Favorite bool `json:"favorite"`
|
Favorite bool `json:"favorite"`
|
||||||
SortOrder int `json:"sortOrder" gorm:"default:0"`
|
SortOrder int `json:"sortOrder" gorm:"default:0"`
|
||||||
|
|
||||||
SyncModelAccount bool `json:"syncModelAccount"`
|
|
||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
|
||||||
|
|
||||||
App App `json:"app" gorm:"-:migration"`
|
App App `json:"app" gorm:"-:migration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ type AppLauncher struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuickDirectory struct {
|
|
||||||
BaseModel
|
|
||||||
Path string `json:"path"`
|
|
||||||
Alias string `json:"alias"`
|
|
||||||
Legacy bool `json:"legacy" gorm:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuickJump struct {
|
type QuickJump struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
|
|||||||
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
|
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
|
||||||
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
|
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
|
||||||
},
|
},
|
||||||
Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
|
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
|
||||||
},
|
},
|
||||||
"bailian-coding-plan": {
|
"bailian-coding-plan": {
|
||||||
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
|
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
|
||||||
@@ -82,6 +82,7 @@ var catalog = map[string]Meta{
|
|||||||
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
||||||
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
|
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
|
||||||
{ID: "glm-5", Name: "GLM-5"},
|
{ID: "glm-5", Name: "GLM-5"},
|
||||||
|
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||||
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
|
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
|
||||||
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
|
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
|
||||||
},
|
},
|
||||||
@@ -113,7 +114,7 @@ var catalog = map[string]Meta{
|
|||||||
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
|
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
|
||||||
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
|
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
|
||||||
},
|
},
|
||||||
Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
|
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}},
|
||||||
},
|
},
|
||||||
"xiaomi": {
|
"xiaomi": {
|
||||||
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
|
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMiniMaxCatalog(t *testing.T) {
|
|
||||||
meta, ok := Get("minimax")
|
|
||||||
if !ok || !reflect.DeepEqual(meta.Models, []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}}) {
|
|
||||||
t.Fatalf("unexpected MiniMax models: %+v", meta.Models)
|
|
||||||
}
|
|
||||||
if meta.DefaultAPIType != "anthropic-messages" {
|
|
||||||
t.Fatalf("changed default protocol: %s", meta.DefaultAPIType)
|
|
||||||
}
|
|
||||||
for _, provider := range All() {
|
|
||||||
for _, model := range provider.Models {
|
|
||||||
if strings.Contains(strings.ToLower(model.ID), "minimax") && model.ID != "MiniMax-M3.1-Flash" && model.ID != "MiniMax-M3" {
|
|
||||||
t.Errorf("legacy suggestion in %s: %s", provider.Key, model.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxVerificationRequests(t *testing.T) {
|
|
||||||
for _, region := range []string{"https://api.minimaxi.com", "https://api.minimax.io"} {
|
|
||||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
|
||||||
for _, auth := range []string{AuthModeXAPIKey, AuthModeBearer} {
|
|
||||||
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "custom-model"} {
|
|
||||||
t.Run(region+"/"+protocol+"/"+auth+"/"+model, func(t *testing.T) {
|
|
||||||
base, endpoint := region+"/v1", "/chat/completions"
|
|
||||||
if protocol == "anthropic-messages" {
|
|
||||||
base, endpoint = region+"/anthropic", "/v1/messages"
|
|
||||||
}
|
|
||||||
built := BuildVerifyRequest("minimax", protocol, auth, base, "test-key", model)
|
|
||||||
if built.URL != base+endpoint {
|
|
||||||
t.Fatalf("URL = %s", built.URL)
|
|
||||||
}
|
|
||||||
var captured map[string]interface{}
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost || r.URL.Path != strings.TrimPrefix(base, region)+endpoint {
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
if protocol == "anthropic-messages" && auth == AuthModeXAPIKey {
|
|
||||||
if r.Header.Get("x-api-key") != "test-key" || r.Header.Get("Authorization") != "" {
|
|
||||||
t.Error("incorrect API key authentication")
|
|
||||||
}
|
|
||||||
} else if r.Header.Get("Authorization") != "Bearer test-key" {
|
|
||||||
t.Error("incorrect bearer authentication")
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
if err := VerifyAccount("minimax", protocol, auth, server.URL+strings.TrimPrefix(base, region), "test-key", model); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if captured["model"] != model || captured["stream"] != false || captured["max_tokens"] != float64(defaultVerifyMaxTokens) {
|
|
||||||
t.Fatalf("unexpected serialized body: %#v", captured)
|
|
||||||
}
|
|
||||||
if model == "MiniMax-M3.1-Flash" {
|
|
||||||
if protocol == "anthropic-messages" {
|
|
||||||
if !reflect.DeepEqual(captured["thinking"], map[string]interface{}{"type": "adaptive"}) || !reflect.DeepEqual(captured["output_config"], map[string]interface{}{"effort": "max"}) || captured["reasoning_effort"] != nil {
|
|
||||||
t.Fatalf("invalid Anthropic Flash reasoning: %#v", captured)
|
|
||||||
}
|
|
||||||
} else if captured["reasoning_effort"] != "max" || captured["thinking"] != nil || captured["output_config"] != nil {
|
|
||||||
t.Fatalf("invalid OpenAI Flash reasoning: %#v", captured)
|
|
||||||
}
|
|
||||||
} else if captured["reasoning_effort"] != nil || captured["thinking"] != nil || captured["output_config"] != nil {
|
|
||||||
t.Fatalf("changed legacy/custom reasoning: %#v", captured)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxExplicitGatewayAndCustomModels(t *testing.T) {
|
|
||||||
for _, provider := range []string{"bailian-coding-plan", "custom", "openrouter"} {
|
|
||||||
for _, protocol := range []string{"openai-completions", "anthropic-messages"} {
|
|
||||||
for _, model := range []string{"MiniMax-M2.5", "MiniMax-M3.1-Flash", "my-custom-model"} {
|
|
||||||
request := BuildVerifyRequest(provider, protocol, AuthModeBearer, "https://example.test/v1", "test-key", model)
|
|
||||||
var body map[string]interface{}
|
|
||||||
if err := json.Unmarshal(request.Body, &body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if body["model"] != model || body["reasoning_effort"] != nil || body["output_config"] != nil || body["thinking"] != nil {
|
|
||||||
t.Fatalf("changed explicit %s request: %#v", provider, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,15 +118,10 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
|||||||
headers["x-api-key"] = apiKey
|
headers["x-api-key"] = apiKey
|
||||||
}
|
}
|
||||||
headers["anthropic-version"] = "2023-06-01"
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
body := map[string]interface{}{
|
request.Body = mustJSON(map[string]interface{}{
|
||||||
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
||||||
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
|
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
|
||||||
}
|
})
|
||||||
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
|
|
||||||
body["thinking"] = map[string]string{"type": "adaptive"}
|
|
||||||
body["output_config"] = map[string]string{"effort": "max"}
|
|
||||||
}
|
|
||||||
request.Body = mustJSON(body)
|
|
||||||
case "openai-responses":
|
case "openai-responses":
|
||||||
request.URL = baseURL + "/responses"
|
request.URL = baseURL + "/responses"
|
||||||
headers["Authorization"] = "Bearer " + apiKey
|
headers["Authorization"] = "Bearer " + apiKey
|
||||||
@@ -136,13 +131,9 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
|||||||
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
|
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
|
||||||
headers["Authorization"] = "Bearer " + apiKey
|
headers["Authorization"] = "Bearer " + apiKey
|
||||||
}
|
}
|
||||||
body := map[string]interface{}{
|
request.Body = mustJSON(map[string]interface{}{
|
||||||
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
||||||
}
|
})
|
||||||
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
|
|
||||||
body["reasoning_effort"] = "max"
|
|
||||||
}
|
|
||||||
request.Body = mustJSON(body)
|
|
||||||
}
|
}
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,9 +17,7 @@ type IAgentAccountRepo interface {
|
|||||||
Save(account *model.AgentAccount) error
|
Save(account *model.AgentAccount) error
|
||||||
DeleteByID(id uint) error
|
DeleteByID(id uint) error
|
||||||
List(opts ...DBOption) ([]model.AgentAccount, error)
|
List(opts ...DBOption) ([]model.AgentAccount, error)
|
||||||
Count() (int64, error)
|
|
||||||
CountTextByProviders(providers []string) (map[string]int64, error)
|
CountTextByProviders(providers []string) (map[string]int64, error)
|
||||||
SaveWithModels(account *model.AgentAccount, models []model.AgentAccountModel) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAgentAccountRepo() IAgentAccountRepo {
|
func NewIAgentAccountRepo() IAgentAccountRepo {
|
||||||
@@ -70,12 +67,6 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
|
|||||||
return accounts, nil
|
return accounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentAccountRepo) Count() (int64, error) {
|
|
||||||
var count int64
|
|
||||||
err := getDb().Model(&model.AgentAccount{}).Count(&count).Error
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
|
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
|
||||||
normalizedProviders := normalizeProviders(providers)
|
normalizedProviders := normalizeProviders(providers)
|
||||||
counts := make(map[string]int64, len(normalizedProviders))
|
counts := make(map[string]int64, len(normalizedProviders))
|
||||||
@@ -106,25 +97,6 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
|
|||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentAccountRepo) SaveWithModels(account *model.AgentAccount, models []model.AgentAccountModel) error {
|
|
||||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Save(account).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(models) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range models {
|
|
||||||
models[i].AccountID = account.ID
|
|
||||||
models[i].SortOrder = i + 1
|
|
||||||
}
|
|
||||||
return tx.Create(&models).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeProviders(providers []string) []string {
|
func normalizeProviders(providers []string) []string {
|
||||||
seen := make(map[string]struct{}, len(providers))
|
seen := make(map[string]struct{}, len(providers))
|
||||||
result := make([]string, 0, len(providers))
|
result := make([]string, 0, len(providers))
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
)
|
)
|
||||||
@@ -16,7 +14,6 @@ type IAiRepo interface {
|
|||||||
Create(cronjob *model.OllamaModel) error
|
Create(cronjob *model.OllamaModel) error
|
||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteAll(ctx context.Context) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAiRepo() IAiRepo {
|
func NewIAiRepo() IAiRepo {
|
||||||
@@ -70,7 +67,3 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
|
|||||||
}
|
}
|
||||||
return db.Delete(&model.OllamaModel{}).Error
|
return db.Delete(&model.OllamaModel{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *AiRepo) DeleteAll(ctx context.Context) error {
|
|
||||||
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -31,7 +30,6 @@ type IAppRepo interface {
|
|||||||
BatchCreate(ctx context.Context, apps []model.App) error
|
BatchCreate(ctx context.Context, apps []model.App) error
|
||||||
Create(ctx context.Context, app *model.App) error
|
Create(ctx context.Context, app *model.App) error
|
||||||
Save(ctx context.Context, app *model.App) error
|
Save(ctx context.Context, app *model.App) error
|
||||||
UpdateIcon(id uint, icon string) error
|
|
||||||
BatchDelete(ctx context.Context, apps []model.App) error
|
BatchDelete(ctx context.Context, apps []model.App) error
|
||||||
DeleteByIDs(ctx context.Context, ids []uint) error
|
DeleteByIDs(ctx context.Context, ids []uint) error
|
||||||
DeleteBy(opts ...DBOption) error
|
DeleteBy(opts ...DBOption) error
|
||||||
@@ -43,10 +41,6 @@ func NewIAppRepo() IAppRepo {
|
|||||||
return &AppRepo{}
|
return &AppRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AppRepo) UpdateIcon(id uint, icon string) error {
|
|
||||||
return getDb(WithByID(id), a.WithResource(constant.AppResourceRemote)).Model(&model.App{}).UpdateColumn("icon", icon).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AppRepo) WithByLikeName(name string) DBOption {
|
func (a AppRepo) WithByLikeName(name string) DBOption {
|
||||||
return func(g *gorm.DB) *gorm.DB {
|
return func(g *gorm.DB) *gorm.DB {
|
||||||
if len(name) == 0 {
|
if len(name) == 0 {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ type IAppDetailRepo interface {
|
|||||||
WithIgnored() DBOption
|
WithIgnored() DBOption
|
||||||
GetFirst(opts ...DBOption) (model.AppDetail, error)
|
GetFirst(opts ...DBOption) (model.AppDetail, error)
|
||||||
Update(ctx context.Context, detail model.AppDetail) error
|
Update(ctx context.Context, detail model.AppDetail) error
|
||||||
CacheCompose(detail model.AppDetail, compose string) (bool, error)
|
|
||||||
MarkDownloaded(detail model.AppDetail) (bool, error)
|
|
||||||
BatchCreate(ctx context.Context, details []model.AppDetail) error
|
BatchCreate(ctx context.Context, details []model.AppDetail) error
|
||||||
DeleteByAppIds(ctx context.Context, appIds []uint) error
|
DeleteByAppIds(ctx context.Context, appIds []uint) error
|
||||||
DeleteByIDs(ctx context.Context, appIds []uint) error
|
DeleteByIDs(ctx context.Context, appIds []uint) error
|
||||||
@@ -59,20 +57,6 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) error
|
|||||||
return getTx(ctx).Save(&detail).Error
|
return getTx(ctx).Save(&detail).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AppDetailRepo) CacheCompose(detail model.AppDetail, compose string) (bool, error) {
|
|
||||||
result := getDb().Model(&model.AppDetail{}).
|
|
||||||
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
|
|
||||||
UpdateColumn("docker_compose", compose)
|
|
||||||
return result.RowsAffected > 0, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AppDetailRepo) MarkDownloaded(detail model.AppDetail) (bool, error) {
|
|
||||||
result := getDb().Model(&model.AppDetail{}).
|
|
||||||
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
|
|
||||||
UpdateColumn("update", false)
|
|
||||||
return result.RowsAffected > 0, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
|
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
|
||||||
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
|
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type IAppInstallRepo interface {
|
|||||||
LoadBaseInfo(key string, name string) (*RootInfo, error)
|
LoadBaseInfo(key string, name string) (*RootInfo, error)
|
||||||
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
|
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
|
||||||
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
|
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
|
||||||
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAppInstallRepo() IAppInstallRepo {
|
func NewIAppInstallRepo() IAppInstallRepo {
|
||||||
@@ -132,12 +131,6 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
|
|||||||
return install, err
|
return install, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
|
|
||||||
var install model.AppInstall
|
|
||||||
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
|
|
||||||
return install, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
|
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
|
||||||
db := getTx(ctx).Model(&model.AppInstall{})
|
db := getTx(ctx).Model(&model.AppInstall{})
|
||||||
return db.Omit(clause.Associations).Create(&install).Error
|
return db.Omit(clause.Associations).Create(&install).Error
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package repo
|
|||||||
import (
|
import (
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LauncherRepo struct{}
|
type LauncherRepo struct{}
|
||||||
@@ -18,11 +17,6 @@ type ILauncherRepo interface {
|
|||||||
GetQuickJump(opts ...DBOption) (model.QuickJump, error)
|
GetQuickJump(opts ...DBOption) (model.QuickJump, error)
|
||||||
ListQuickJump(withAll bool) []model.QuickJump
|
ListQuickJump(withAll bool) []model.QuickJump
|
||||||
UpdateQuicks(quicks []model.QuickJump) error
|
UpdateQuicks(quicks []model.QuickJump) error
|
||||||
ListQuickDirectories() ([]model.QuickDirectory, error)
|
|
||||||
CountQuickDirectories() (int64, error)
|
|
||||||
CreateQuickDirectory(item *model.QuickDirectory) error
|
|
||||||
UpdateQuickDirectory(item *model.QuickDirectory) error
|
|
||||||
DeleteQuickDirectory(id uint, legacy bool) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewILauncherRepo() ILauncherRepo {
|
func NewILauncherRepo() ILauncherRepo {
|
||||||
@@ -86,10 +80,10 @@ func (u *LauncherRepo) ListQuickJump(withAll bool) []model.QuickJump {
|
|||||||
}
|
}
|
||||||
if !withAll && len(quicks) == 0 {
|
if !withAll && len(quicks) == 0 {
|
||||||
return []model.QuickJump{
|
return []model.QuickJump{
|
||||||
{Name: "ModelServices", Title: "aiTools.model.model", Recommend: 0, IsShow: true, Router: "/ai/model"},
|
|
||||||
{Name: "Agent", Title: "aiTools.agents.agent", Recommend: 1, IsShow: true, Router: "/ai/agents/agent"},
|
{Name: "Agent", Title: "aiTools.agents.agent", Recommend: 1, IsShow: true, Router: "/ai/agents/agent"},
|
||||||
{Name: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
|
{Name: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
|
||||||
{Name: "Database", Title: "menu.database", Recommend: 30, IsShow: true, Router: "/databases"},
|
{Name: "Database", Title: "menu.database", Recommend: 30, IsShow: true, Router: "/databases"},
|
||||||
|
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: false, Router: "/cronjobs"},
|
||||||
{Name: "AppInstalled", Title: "home.appInstalled", Recommend: 70, IsShow: true, Router: "/apps/installed"},
|
{Name: "AppInstalled", Title: "home.appInstalled", Recommend: 70, IsShow: true, Router: "/apps/installed"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,73 +95,13 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
|
|||||||
for _, item := range quicks {
|
for _, item := range quicks {
|
||||||
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||||
"is_show": item.IsShow,
|
"is_show": item.IsShow,
|
||||||
|
"detail": item.Detail,
|
||||||
|
"alias": item.Alias,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit().Error
|
tx.Commit()
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy entries remain in the overview configuration until explicitly deleted.
|
|
||||||
func legacyQuickDirectories(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Model(&model.QuickJump{}).Where("name = ? AND is_show = ? AND detail <> ?", "File", true, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *LauncherRepo) ListQuickDirectories() ([]model.QuickDirectory, error) {
|
|
||||||
items := make([]model.QuickDirectory, 0)
|
|
||||||
if err := global.DB.Order("id ASC").Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var quicks []model.QuickJump
|
|
||||||
if err := legacyQuickDirectories(global.DB).Order("id ASC").Find(&quicks).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, quick := range quicks {
|
|
||||||
items = append(items, model.QuickDirectory{BaseModel: quick.BaseModel, Path: quick.Detail, Alias: quick.Alias, Legacy: true})
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
func (u *LauncherRepo) CountQuickDirectories() (int64, error) {
|
|
||||||
var count, legacyCount int64
|
|
||||||
if err := global.DB.Model(&model.QuickDirectory{}).Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if err := legacyQuickDirectories(global.DB).Count(&legacyCount).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count + legacyCount, nil
|
|
||||||
}
|
|
||||||
func (u *LauncherRepo) CreateQuickDirectory(item *model.QuickDirectory) error {
|
|
||||||
return global.DB.Create(item).Error
|
|
||||||
}
|
|
||||||
func (u *LauncherRepo) UpdateQuickDirectory(item *model.QuickDirectory) error {
|
|
||||||
var result *gorm.DB
|
|
||||||
if item.Legacy {
|
|
||||||
result = legacyQuickDirectories(global.DB).Where("id = ?", item.ID).Updates(map[string]interface{}{"detail": item.Path, "alias": item.Alias})
|
|
||||||
} else {
|
|
||||||
result = global.DB.Model(&model.QuickDirectory{}).Where("id = ?", item.ID).Updates(map[string]interface{}{"path": item.Path, "alias": item.Alias})
|
|
||||||
}
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (u *LauncherRepo) DeleteQuickDirectory(id uint, legacy bool) error {
|
|
||||||
var result *gorm.DB
|
|
||||||
if legacy {
|
|
||||||
result = legacyQuickDirectories(global.DB).Where("id = ?", id).Updates(map[string]interface{}{"detail": nil, "alias": nil})
|
|
||||||
} else {
|
|
||||||
result = global.DB.Delete(&model.QuickDirectory{}, id)
|
|
||||||
}
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,8 +20,6 @@ type IDatabaseRepo interface {
|
|||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
Delete(ctx context.Context, opts ...DBOption) error
|
Delete(ctx context.Context, opts ...DBOption) error
|
||||||
Get(opts ...DBOption) (model.Database, error)
|
Get(opts ...DBOption) (model.Database, error)
|
||||||
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
|
|
||||||
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
|
|
||||||
WithoutByFrom(from string) DBOption
|
WithoutByFrom(from string) DBOption
|
||||||
WithAppInstallID(appInstallID uint) DBOption
|
WithAppInstallID(appInstallID uint) DBOption
|
||||||
WithTypeList(dbType string) DBOption
|
WithTypeList(dbType string) DBOption
|
||||||
@@ -32,32 +29,6 @@ func NewIDatabaseRepo() IDatabaseRepo {
|
|||||||
return &DatabaseRepo{}
|
return &DatabaseRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstalledLocalMysql struct {
|
|
||||||
ID uint
|
|
||||||
Type, From, Database string
|
|
||||||
ContainerName, Version, Env string
|
|
||||||
}
|
|
||||||
|
|
||||||
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
|
|
||||||
return getTx(ctx).WithContext(ctx).Table("databases AS d").
|
|
||||||
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
|
|
||||||
Joins("JOIN apps AS a ON a.id = ai.app_id").
|
|
||||||
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
|
|
||||||
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
|
|
||||||
result := make([]InstalledLocalMysql, 0)
|
|
||||||
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
|
|
||||||
var result InstalledLocalMysql
|
|
||||||
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
|
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
|
||||||
var database model.Database
|
var database model.Database
|
||||||
db := global.DB
|
db := global.DB
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type IMysqlRepo interface {
|
|||||||
List(opts ...DBOption) ([]model.DatabaseMysql, error)
|
List(opts ...DBOption) ([]model.DatabaseMysql, error)
|
||||||
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
|
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
|
||||||
Create(ctx context.Context, mysql *model.DatabaseMysql) error
|
Create(ctx context.Context, mysql *model.DatabaseMysql) error
|
||||||
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
|
|
||||||
Delete(ctx context.Context, opts ...DBOption) error
|
Delete(ctx context.Context, opts ...DBOption) error
|
||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
DeleteLocal(ctx context.Context) error
|
DeleteLocal(ctx context.Context) error
|
||||||
@@ -95,10 +94,6 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
|
|||||||
return getTx(ctx).Create(mysql).Error
|
return getTx(ctx).Create(mysql).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Create(mysql).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
|
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
|
||||||
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
|
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ type DatabaseUserRepo struct{}
|
|||||||
|
|
||||||
type IDatabaseUserRepo interface {
|
type IDatabaseUserRepo interface {
|
||||||
Get(opts ...DBOption) (model.DatabaseUser, error)
|
Get(opts ...DBOption) (model.DatabaseUser, error)
|
||||||
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
|
|
||||||
List(opts ...DBOption) ([]model.DatabaseUser, error)
|
List(opts ...DBOption) ([]model.DatabaseUser, error)
|
||||||
Save(user *model.DatabaseUser) error
|
Save(user *model.DatabaseUser) error
|
||||||
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
|
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteBy(ctx context.Context, opts ...DBOption) error
|
DeleteBy(ctx context.Context, opts ...DBOption) error
|
||||||
Update(vars map[string]interface{}, opts ...DBOption) error
|
Update(vars map[string]interface{}, opts ...DBOption) error
|
||||||
@@ -47,13 +45,6 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEncryptedWithContext leaves credential transformation to the caller.
|
|
||||||
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
|
|
||||||
var user model.DatabaseUser
|
|
||||||
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
|
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
|
||||||
var users []model.DatabaseUser
|
var users []model.DatabaseUser
|
||||||
db := global.DB.Model(&model.DatabaseUser{})
|
db := global.DB.Model(&model.DatabaseUser{})
|
||||||
@@ -84,10 +75,6 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
|
|||||||
return global.DB.Save(user).Error
|
return global.DB.Save(user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Save(user).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
|
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
|
||||||
db := global.DB
|
db := global.DB
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ type IDatabaseUserGrantRepo interface {
|
|||||||
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
|
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
|
||||||
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
|
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
|
||||||
Save(grant *model.DatabaseUserGrant) error
|
Save(grant *model.DatabaseUserGrant) error
|
||||||
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
|
|
||||||
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
|
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteBy(ctx context.Context, opts ...DBOption) error
|
DeleteBy(ctx context.Context, opts ...DBOption) error
|
||||||
@@ -53,12 +52,6 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
|
|||||||
return global.DB.Save(grant).Error
|
return global.DB.Save(grant).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).
|
|
||||||
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
|
|
||||||
FirstOrCreate(grant).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
|
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
|
||||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
|
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
|
||||||
|
|||||||
@@ -15,13 +15,10 @@ type SettingRepo struct{}
|
|||||||
type ISettingRepo interface {
|
type ISettingRepo interface {
|
||||||
GetList(opts ...DBOption) ([]model.Setting, error)
|
GetList(opts ...DBOption) ([]model.Setting, error)
|
||||||
Get(opts ...DBOption) (model.Setting, error)
|
Get(opts ...DBOption) (model.Setting, error)
|
||||||
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
|
|
||||||
GetValueByKey(key string) (string, error)
|
GetValueByKey(key string) (string, error)
|
||||||
GetCoreValueByKey(key string) (string, error)
|
GetCoreValueByKey(key string) (string, error)
|
||||||
Create(key, value string) error
|
Create(key, value string) error
|
||||||
CreateWithContext(ctx context.Context, setting *model.Setting) error
|
|
||||||
Update(key, value string) error
|
Update(key, value string) error
|
||||||
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
|
|
||||||
WithByKey(key string) DBOption
|
WithByKey(key string) DBOption
|
||||||
|
|
||||||
UpdateOrCreate(key, value string) error
|
UpdateOrCreate(key, value string) error
|
||||||
@@ -70,22 +67,6 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
|
|||||||
return settings, err
|
return settings, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
|
|
||||||
var setting model.Setting
|
|
||||||
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
|
|
||||||
return setting, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Create(setting).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
|
|
||||||
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
|
|
||||||
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) GetValueByKey(key string) (string, error) {
|
func (s *SettingRepo) GetValueByKey(key string) (string, error) {
|
||||||
var setting model.Setting
|
var setting model.Setting
|
||||||
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
|
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
|
||||||
|
|||||||
+6
-19
@@ -19,8 +19,8 @@ type ITaskRepo interface {
|
|||||||
GetFirst(opts ...DBOption) (model.Task, error)
|
GetFirst(opts ...DBOption) (model.Task, error)
|
||||||
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
|
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
|
||||||
Update(ctx context.Context, task *model.Task) error
|
Update(ctx context.Context, task *model.Task) error
|
||||||
UpdateRunningTaskToFailed(opts ...DBOption) error
|
UpdateRunningTaskToFailed() error
|
||||||
CountExecutingTask(opts ...DBOption) (int64, error)
|
CountExecutingTask() (int64, error)
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteAll() error
|
DeleteAll() error
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ type ITaskRepo interface {
|
|||||||
WithResourceID(id uint) DBOption
|
WithResourceID(id uint) DBOption
|
||||||
WithOperate(taskOperate string) DBOption
|
WithOperate(taskOperate string) DBOption
|
||||||
WithByStatus(status string) DBOption
|
WithByStatus(status string) DBOption
|
||||||
WithoutScopes(scopes ...string) DBOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewITaskRepo() ITaskRepo {
|
func NewITaskRepo() ITaskRepo {
|
||||||
@@ -111,30 +110,18 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
|
|||||||
return getTaskTx(ctx).Save(&task).Error
|
return getTaskTx(ctx).Save(&task).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
|
func (t TaskRepo) UpdateRunningTaskToFailed() error {
|
||||||
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
|
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
|
||||||
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
|
|
||||||
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
|
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
|
func (t TaskRepo) CountExecutingTask() (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
|
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error
|
||||||
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
|
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
|
|
||||||
excluded := append([]string(nil), scopes...)
|
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
|
||||||
if len(excluded) == 0 {
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
return db.Where("type IS NULL OR type NOT IN ?", excluded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t TaskRepo) Delete(opts ...DBOption) error {
|
func (t TaskRepo) Delete(opts ...DBOption) error {
|
||||||
db := global.TaskDB
|
db := global.TaskDB
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|||||||
+9
-153
@@ -88,7 +88,6 @@ type IAgentService interface {
|
|||||||
|
|
||||||
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
|
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
|
||||||
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
|
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
|
||||||
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
|
|
||||||
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
|
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
|
||||||
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
|
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
|
||||||
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
|
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
|
||||||
@@ -143,9 +142,6 @@ const (
|
|||||||
|
|
||||||
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||||
agentType := req.AgentType
|
agentType := req.AgentType
|
||||||
if err := validateNativeAgentCreate(req); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := checkPortExist(req.WebUIPort); err != nil {
|
if err := checkPortExist(req.WebUIPort); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -241,8 +237,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
|||||||
}
|
}
|
||||||
} else if agentType == constant.AppCopaw {
|
} else if agentType == constant.AppCopaw {
|
||||||
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||||
} else if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOctop {
|
|
||||||
dashboardAuth = agentDashboardAuth{Username: req.DashboardUsername, Password: req.DashboardPassword}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]interface{}{
|
params := map[string]interface{}{
|
||||||
@@ -251,12 +245,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
|||||||
constant.HostIP: "",
|
constant.HostIP: "",
|
||||||
}
|
}
|
||||||
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
|
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
|
||||||
if agentType == constant.AppDeepSeekHarness {
|
|
||||||
params["HTTPS_ACCESS_HOST"] = req.AccessHost
|
|
||||||
}
|
|
||||||
if agentType == constant.AppOctop {
|
|
||||||
params["OCTOP_LOG_LEVEL"] = "info"
|
|
||||||
}
|
|
||||||
if agentType == constant.AppOpenclaw {
|
if agentType == constant.AppOpenclaw {
|
||||||
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
|
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
|
||||||
params["ALLOWED_ORIGIN"] = allowedOrigin
|
params["ALLOWED_ORIGIN"] = allowedOrigin
|
||||||
@@ -750,7 +738,7 @@ func requiresAgentAccount(agentType string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
|
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
|
||||||
if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
|
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
|
||||||
params["PANEL_APP_PORT_HTTPS"] = webUIPort
|
params["PANEL_APP_PORT_HTTPS"] = webUIPort
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1044,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
|
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1105,33 +1093,16 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Models != nil {
|
|
||||||
if account.AppInstallID != 0 {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "local model pool is managed by the deployment", nil)
|
|
||||||
}
|
|
||||||
models, err = normalizeAgentAccountModels(account, req.Models)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := ensureAccountModelsNotBound(account, models); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestedVerifyModel := req.VerifyModel
|
requestedVerifyModel := req.VerifyModel
|
||||||
if strings.TrimSpace(requestedVerifyModel) == "" {
|
if strings.TrimSpace(requestedVerifyModel) == "" {
|
||||||
if _, ok := findAgentAccountModelForProvider(provider, models, account.VerifyModel); ok {
|
requestedVerifyModel = account.VerifyModel
|
||||||
requestedVerifyModel = account.VerifyModel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
verifyModel := ""
|
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
|
||||||
if account.AppInstallID == 0 || len(models) > 0 {
|
if err != nil {
|
||||||
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
|
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1145,16 +1116,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
|||||||
account.Remark = req.Remark
|
account.Remark = req.Remark
|
||||||
account.Verified = true
|
account.Verified = true
|
||||||
|
|
||||||
if req.Models != nil {
|
if err := global.DB.Save(account).Error; err != nil {
|
||||||
rows := make([]model.AgentAccountModel, 0, len(models))
|
|
||||||
for _, item := range models {
|
|
||||||
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
|
|
||||||
}
|
|
||||||
err = agentAccountRepo.SaveWithModels(account, rows)
|
|
||||||
} else {
|
|
||||||
err = agentAccountRepo.Save(account)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
terminalai.InvalidateTerminalRuntimeCache()
|
terminalai.InvalidateTerminalRuntimeCache()
|
||||||
@@ -1185,34 +1147,13 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
installIDs := make([]uint, 0, len(list))
|
|
||||||
for _, account := range list {
|
|
||||||
if account.AppInstallID != 0 {
|
|
||||||
installIDs = append(installIDs, account.AppInstallID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localInstalls := make(map[uint]bool, len(installIDs))
|
|
||||||
if len(installIDs) > 0 {
|
|
||||||
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
for _, install := range installs {
|
|
||||||
localInstalls[install.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items := make([]dto.AgentAccountInfo, 0, len(list))
|
items := make([]dto.AgentAccountInfo, 0, len(list))
|
||||||
for _, item := range list {
|
for _, item := range list {
|
||||||
apiKey := ""
|
apiKey := ""
|
||||||
if item.RememberAPIKey {
|
if item.RememberAPIKey {
|
||||||
apiKey = item.APIKey
|
apiKey = item.APIKey
|
||||||
}
|
}
|
||||||
appInstallID := uint(0)
|
|
||||||
if localInstalls[item.AppInstallID] {
|
|
||||||
appInstallID = item.AppInstallID
|
|
||||||
}
|
|
||||||
items = append(items, dto.AgentAccountInfo{
|
items = append(items, dto.AgentAccountInfo{
|
||||||
AppInstallID: appInstallID,
|
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
MasterAccountID: item.MasterAccountID,
|
MasterAccountID: item.MasterAccountID,
|
||||||
Provider: item.Provider,
|
Provider: item.Provider,
|
||||||
@@ -1298,31 +1239,6 @@ func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) err
|
|||||||
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
|
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
|
||||||
return buserr.New("ErrRecordExist")
|
return buserr.New("ErrRecordExist")
|
||||||
}
|
}
|
||||||
if account.Provider == "ollama" {
|
|
||||||
if account.AppInstallID != 0 {
|
|
||||||
install, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if install.App.Key != "ollama" || !install.SyncModelAccount {
|
|
||||||
return buserr.New("ErrAgentAccountUnavailable")
|
|
||||||
}
|
|
||||||
localModels, err := aiRepo.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, item := range localModels {
|
|
||||||
if sameProviderModelID("ollama", item.Name, nextModel.ID) {
|
|
||||||
return (&AIToolService{}).Recreate(dto.OllamaModelName{Name: item.Name, TaskID: req.TaskID})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (&AIToolService{}).Create(dto.OllamaModelName{Name: nextModel.ID, TaskID: req.TaskID})
|
|
||||||
}
|
|
||||||
nextModel.ID, err = resolveExistingOllamaModel(account, nextModel.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortOrder := len(models) + 1
|
sortOrder := len(models) + 1
|
||||||
record := &model.AgentAccountModel{
|
record := &model.AgentAccountModel{
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
@@ -1353,15 +1269,6 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if account.Provider == "ollama" && !sameProviderModelID("ollama", record.Model, nextModel.ID) {
|
|
||||||
if account.AppInstallID != 0 {
|
|
||||||
return buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
nextModel.ID, err = resolveExistingOllamaModel(account, nextModel.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextModel.RecordID = req.Model.RecordID
|
nextModel.RecordID = req.Model.RecordID
|
||||||
for _, item := range models {
|
for _, item := range models {
|
||||||
if item.RecordID == req.Model.RecordID {
|
if item.RecordID == req.Model.RecordID {
|
||||||
@@ -1400,10 +1307,6 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error {
|
func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
agentAccountMutationMu.Lock()
|
|
||||||
defer agentAccountMutationMu.Unlock()
|
|
||||||
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
|
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1429,44 +1332,6 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
|
|||||||
if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
|
if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.DeleteFiles && account.Provider != "ollama" {
|
|
||||||
return buserr.New("ErrAgentProviderNotSupported")
|
|
||||||
}
|
|
||||||
if req.DeleteFiles {
|
|
||||||
if account.AppInstallID != 0 {
|
|
||||||
install, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if install.App.Key != "ollama" {
|
|
||||||
return buserr.New("ErrAgentAccountUnavailable")
|
|
||||||
}
|
|
||||||
localModels, err := aiRepo.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
localIDs := make([]uint, 0)
|
|
||||||
for _, item := range localModels {
|
|
||||||
if !sameProviderModelID("ollama", item.Name, record.Model) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.Status == constant.StatusWaiting {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
localIDs = append(localIDs, item.ID)
|
|
||||||
}
|
|
||||||
if err := cmd.NewCommandMgr().Run("docker", "exec", install.ContainerName, "ollama", "rm", record.Model); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(localIDs) > 0 {
|
|
||||||
if err := aiRepo.Delete(repo.WithByIDs(localIDs)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if err := deleteRemoteOllamaModel(account, record.Model); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := agentAccountModelRepo.DeleteByID(req.RecordID); err != nil {
|
if err := agentAccountModelRepo.DeleteByID(req.RecordID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1486,18 +1351,13 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
||||||
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
|
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
||||||
agentAccountMutationMu.Lock()
|
agentAccountMutationMu.Lock()
|
||||||
defer agentAccountMutationMu.Unlock()
|
defer agentAccountMutationMu.Unlock()
|
||||||
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := global.DB.Transaction(func(tx *gorm.DB) error {
|
if err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
var agentCount int64
|
var agentCount int64
|
||||||
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
|
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
|
||||||
@@ -1682,10 +1542,6 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer feishuSwitches.Delete(agent.ConfigPath)
|
|
||||||
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
|
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,29 +45,9 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
result := extractFeishuConfig(conf)
|
|
||||||
result.Installed, _ = checkPluginInstalled(install.GetPath(), "feishu")
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := extractFeishuConfig(conf)
|
result := extractFeishuConfig(conf)
|
||||||
if !legacy {
|
installed, _ := checkPluginInstalled(install.GetPath(), "feishu")
|
||||||
result = extractBundledFeishuConfig(conf)
|
result.Installed = installed
|
||||||
}
|
|
||||||
result.BuiltinManaged = true
|
|
||||||
result.BuiltinAvailable = hasBundledFeishu(install.ContainerName)
|
|
||||||
result.Installed = result.BuiltinAvailable
|
|
||||||
result.Legacy, result.LegacyConfigured = legacy, configured
|
|
||||||
if legacy {
|
|
||||||
result.GroupSessionScope = "group"
|
|
||||||
if result.ThreadSession {
|
|
||||||
result.GroupSessionScope = "group_topic"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +72,6 @@ func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) err
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return saveBundledFeishuConfig(agent, install, req)
|
|
||||||
}
|
|
||||||
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
|
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
|
||||||
config := dto.AgentFeishuConfig{
|
config := dto.AgentFeishuConfig{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
@@ -381,9 +358,6 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -429,9 +403,6 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -472,9 +443,6 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -570,13 +538,10 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
|
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
|
||||||
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
_, install, err := a.loadAgentAndInstall(req.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return &dto.AgentPluginStatus{Installed: hasBundledFeishu(install.ContainerName)}, nil
|
|
||||||
}
|
|
||||||
installed, err := checkPluginInstalled(install.GetPath(), req.Type)
|
installed, err := checkPluginInstalled(install.GetPath(), req.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,677 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep panel configuration writes out of the backup/activation/recovery window.
|
|
||||||
var feishuSwitches sync.Map
|
|
||||||
|
|
||||||
func isOpenclawBundledFeishuVersion(version string) bool {
|
|
||||||
return common.CompareAppVersion(version, "2026.10.7")
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasBundledFeishu(container string) bool {
|
|
||||||
image, err := feishuContainerImage(container)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err = bundledFeishuImage(image)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func usesBundledFeishu(conf map[string]interface{}) bool {
|
|
||||||
f := getChannelConfig(conf, "feishu")
|
|
||||||
for _, key := range []string{"threadSession", "replyMode"} {
|
|
||||||
if _, old := f[key]; old {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, old := f["streaming"].(bool); old {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
entries := childMap(childMap(conf, "plugins"), "entries")
|
|
||||||
if extractBoolValue(childMap(entries, "openclaw-lark")["enabled"], false) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, streaming := f["streaming"].(map[string]interface{})
|
|
||||||
_, selected := entries["feishu"]
|
|
||||||
return selected && (extractBoolValue(childMap(entries, "feishu")["enabled"], false) || f["groupSessionScope"] != nil || streaming)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasLegacyFeishu(conf map[string]interface{}, installPath string) bool {
|
|
||||||
legacy, _, err := classifyLegacyFeishu(conf, installPath)
|
|
||||||
return err != nil || legacy
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectBundledFeishu(conf map[string]interface{}, enabled bool) {
|
|
||||||
plugins := ensureChildMap(conf, "plugins")
|
|
||||||
entries := ensureChildMap(plugins, "entries")
|
|
||||||
if legacy, ok := entries["openclaw-lark"]; ok {
|
|
||||||
if entry, ok := legacy.(map[string]interface{}); ok {
|
|
||||||
entry["enabled"] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ensureChildMap(entries, "feishu")["enabled"] = enabled
|
|
||||||
// Preserve an absent allowlist; creating one would disable unrelated plugins.
|
|
||||||
if allow, ok := plugins["allow"]; ok && len(extractStringList(allow)) > 0 {
|
|
||||||
ids := slices.DeleteFunc(extractStringList(allow), func(id string) bool { return id == "openclaw-lark" })
|
|
||||||
if !slices.Contains(ids, "feishu") {
|
|
||||||
ids = append(ids, "feishu")
|
|
||||||
}
|
|
||||||
plugins["allow"] = ids
|
|
||||||
}
|
|
||||||
if deny, ok := plugins["deny"]; ok {
|
|
||||||
plugins["deny"] = slices.DeleteFunc(extractStringList(deny), func(id string) bool { return id == "feishu" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuConfigError(field string) error { return buserr.WithDetail("ErrFeishuConfig", field, nil) }
|
|
||||||
|
|
||||||
func convertLegacyFeishu(conf map[string]interface{}, req dto.AgentFeishuSwitchReq) error {
|
|
||||||
f := getChannelConfig(conf, "feishu")
|
|
||||||
if len(f) == 0 {
|
|
||||||
if raw, exists := childMap(conf, "channels")["feishu"]; exists && raw != nil {
|
|
||||||
if _, ok := raw.(map[string]interface{}); !ok {
|
|
||||||
return feishuConfigError("channels.feishu")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f = ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
|
|
||||||
f["enabled"], f["dmPolicy"], f["groupPolicy"] = false, "pairing", "allowlist"
|
|
||||||
}
|
|
||||||
if err := checkLegacyFeishuFields(f, "channels.feishu", false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := convertFeishuAccess(f, "channels.feishu"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
delete(f, "threadSession")
|
|
||||||
delete(f, "replyMode")
|
|
||||||
f["streaming"] = map[string]interface{}{"mode": "off"}
|
|
||||||
if req.Streaming {
|
|
||||||
f["streaming"] = map[string]interface{}{"mode": "partial"}
|
|
||||||
}
|
|
||||||
f["groupSessionScope"] = req.GroupSessionScope
|
|
||||||
f["requireMention"] = req.RequireMention == "true"
|
|
||||||
delete(f, "topicSessionMode")
|
|
||||||
// Preserve legacy defaults explicitly; bundled defaults are more restrictive.
|
|
||||||
if _, ok := f["groupPolicy"]; !ok {
|
|
||||||
f["groupPolicy"] = "open"
|
|
||||||
}
|
|
||||||
entries := ensureChildMap(ensureChildMap(conf, "plugins"), "entries")
|
|
||||||
ensureChildMap(entries, "openclaw-lark")["enabled"] = false
|
|
||||||
selectBundledFeishu(conf, extractBoolValue(f["enabled"], true))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit automatic conversion to the shared channel fields; custom plugin options require review.
|
|
||||||
func checkLegacyFeishuFields(f map[string]interface{}, field string, group bool) error {
|
|
||||||
allowed := strings.Fields("enabled name appId appSecret encryptKey verificationToken domain connectionMode webhookPath defaultAccount dmPolicy allowFrom groupPolicy groupAllowFrom groupSenderAllowFrom requireMention accounts groups streaming threadSession replyMode groupSessionScope topicSessionMode replyInThread")
|
|
||||||
if group {
|
|
||||||
allowed = strings.Fields("enabled requireMention allowFrom groupSessionScope topicSessionMode replyInThread")
|
|
||||||
}
|
|
||||||
for _, key := range sortedChildKeys(f) {
|
|
||||||
if !slices.Contains(allowed, key) {
|
|
||||||
return feishuConfigError(field + "." + key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if enabled, exists := f["enabled"]; exists {
|
|
||||||
if _, ok := enabled.(bool); !ok {
|
|
||||||
return feishuConfigError(field + ".enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, key := range []string{"dmPolicy", "groupPolicy"} {
|
|
||||||
if raw, exists := f[key]; exists {
|
|
||||||
value, ok := raw.(string)
|
|
||||||
options := []string{"open", "allowlist", "disabled"}
|
|
||||||
if key == "dmPolicy" {
|
|
||||||
options = append(options, "pairing")
|
|
||||||
}
|
|
||||||
if !ok || !slices.Contains(options, value) {
|
|
||||||
return feishuConfigError(field + "." + key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, key := range []string{"allowFrom", "groupSenderAllowFrom"} {
|
|
||||||
if raw, exists := f[key]; exists {
|
|
||||||
values, ok := raw.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(field + "." + key)
|
|
||||||
}
|
|
||||||
for _, value := range values {
|
|
||||||
if _, ok := value.(string); !ok {
|
|
||||||
return feishuConfigError(field + "." + key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, section := range []string{"accounts", "groups"} {
|
|
||||||
raw, exists := f[section]
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries, ok := raw.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(field + "." + section)
|
|
||||||
}
|
|
||||||
for _, id := range sortedChildKeys(entries) {
|
|
||||||
entry, ok := entries[id].(map[string]interface{})
|
|
||||||
name := field + "." + section + "." + id
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(name)
|
|
||||||
}
|
|
||||||
if err := checkLegacyFeishuFields(entry, name, section == "groups"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertFeishuAccess(f map[string]interface{}, field string) error {
|
|
||||||
if raw, ok := f["groupAllowFrom"]; ok {
|
|
||||||
values, ok := raw.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(field + ".groupAllowFrom")
|
|
||||||
}
|
|
||||||
groups, senders := []string{}, []string{}
|
|
||||||
for _, value := range values {
|
|
||||||
id, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(field + ".groupAllowFrom")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(id, "oc_"):
|
|
||||||
groups = append(groups, id)
|
|
||||||
case strings.HasPrefix(id, "ou_"):
|
|
||||||
senders = append(senders, id)
|
|
||||||
default:
|
|
||||||
return feishuConfigError(field + ".groupAllowFrom")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A sender-only legacy allowlist has different group admission semantics.
|
|
||||||
if len(senders) > 0 && (len(groups) == 0 || f["groupSenderAllowFrom"] != nil) {
|
|
||||||
return feishuConfigError(field + ".groupAllowFrom")
|
|
||||||
}
|
|
||||||
f["groupAllowFrom"] = groups
|
|
||||||
if len(senders) > 0 {
|
|
||||||
f["groupSenderAllowFrom"] = senders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, section := range []string{"accounts", "groups"} {
|
|
||||||
for id, value := range childMap(f, section) {
|
|
||||||
entry, ok := value.(map[string]interface{})
|
|
||||||
name := field + "." + section + "." + id
|
|
||||||
if !ok {
|
|
||||||
return feishuConfigError(name)
|
|
||||||
}
|
|
||||||
// Account/group overrides must be resolved explicitly, not flattened.
|
|
||||||
for _, key := range []string{"threadSession", "replyMode"} {
|
|
||||||
if _, ok := entry[key]; ok {
|
|
||||||
return feishuConfigError(name + "." + key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if mention, ok := entry["requireMention"]; ok {
|
|
||||||
if _, ok := mention.(bool); !ok {
|
|
||||||
return feishuConfigError(name + ".requireMention")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if raw, exists := entry["streaming"]; exists {
|
|
||||||
streaming, ok := raw.(bool)
|
|
||||||
if !ok || streaming {
|
|
||||||
return feishuConfigError(name + ".streaming")
|
|
||||||
}
|
|
||||||
entry["streaming"] = map[string]interface{}{"mode": "off"}
|
|
||||||
}
|
|
||||||
if err := convertFeishuAccess(entry, name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractBundledFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig {
|
|
||||||
result := extractFeishuConfig(conf)
|
|
||||||
f := getChannelConfig(conf, "feishu")
|
|
||||||
result.Enabled = extractBoolValue(f["enabled"], true) && extractBoolValue(childMap(childMap(childMap(conf, "plugins"), "entries"), "feishu")["enabled"], true)
|
|
||||||
result.GroupSessionScope = extractStringValue(f["groupSessionScope"])
|
|
||||||
if result.GroupSessionScope == "" {
|
|
||||||
result.GroupSessionScope = "group"
|
|
||||||
}
|
|
||||||
result.Streaming = extractStringValue(childMap(f, "streaming")["mode"]) != "off"
|
|
||||||
result.GroupPolicy = extractStringValue(f["groupPolicy"])
|
|
||||||
if result.GroupPolicy == "" {
|
|
||||||
result.GroupPolicy = "allowlist"
|
|
||||||
}
|
|
||||||
if len(f) == 0 {
|
|
||||||
result.Streaming = false
|
|
||||||
result.RequireMention = "true"
|
|
||||||
} else if _, ok := f["requireMention"]; !ok {
|
|
||||||
result.RequireMention = "open"
|
|
||||||
}
|
|
||||||
accounts := childMap(f, "accounts")
|
|
||||||
ids := sortedChildKeys(accounts)
|
|
||||||
if (f["appId"] != nil || f["appSecret"] != nil) && !slices.Contains(ids, "default") {
|
|
||||||
ids = append([]string{"default"}, ids...)
|
|
||||||
}
|
|
||||||
result.Bots = []dto.AgentFeishuBot{}
|
|
||||||
for _, id := range ids {
|
|
||||||
effective := make(map[string]interface{}, len(f))
|
|
||||||
for k, v := range f {
|
|
||||||
effective[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range childMap(accounts, id) {
|
|
||||||
effective[k] = v
|
|
||||||
}
|
|
||||||
bot := defaultFeishuBot()
|
|
||||||
bot.AccountID, bot.IsDefault = id, id == "default"
|
|
||||||
bot.Name = extractDisplayName(childMap(accounts, id), "", id)
|
|
||||||
bot.Enabled = extractBoolValue(childMap(accounts, id)["enabled"], true)
|
|
||||||
bot.AppID, bot.AppSecret = extractStringValue(effective["appId"]), extractStringValue(effective["appSecret"])
|
|
||||||
bot.DmPolicy = extractStringValue(effective["dmPolicy"])
|
|
||||||
if bot.DmPolicy == "" {
|
|
||||||
bot.DmPolicy = "pairing"
|
|
||||||
}
|
|
||||||
bot.AllowFrom = extractStringList(effective["allowFrom"])
|
|
||||||
result.Bots = append(result.Bots, bot)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBundledFeishuConfig(conf map[string]interface{}, req dto.AgentFeishuConfigUpdateReq) error {
|
|
||||||
if !slices.Contains([]string{"group", "group_sender", "group_topic", "group_topic_sender"}, req.GroupSessionScope) {
|
|
||||||
return feishuConfigError("groupSessionScope")
|
|
||||||
}
|
|
||||||
f := ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
|
|
||||||
accounts := childMap(f, "accounts")
|
|
||||||
seen := map[string]bool{}
|
|
||||||
appIDs := map[string]bool{}
|
|
||||||
for _, bot := range req.Bots {
|
|
||||||
if bot.AccountID == "" || bot.AccountID != strings.ToLower(strings.TrimSpace(bot.AccountID)) || seen[bot.AccountID] || appIDs[bot.AppID] || bot.AppID == "" || !slices.Contains([]string{"pairing", "open", "allowlist", "disabled"}, bot.DmPolicy) || (bot.DmPolicy == "allowlist" && len(bot.AllowFrom) == 0) {
|
|
||||||
return feishuConfigError("accounts")
|
|
||||||
}
|
|
||||||
seen[bot.AccountID] = true
|
|
||||||
appIDs[bot.AppID] = true
|
|
||||||
entry := childMap(accounts, bot.AccountID)
|
|
||||||
if _, exists := entry["appSecret"]; !exists && bot.AppSecret == "" {
|
|
||||||
entry["appSecret"] = f["appSecret"]
|
|
||||||
}
|
|
||||||
// SecretRef credentials cannot be represented by the form: keep them intact.
|
|
||||||
if _, secretRef := entry["appSecret"].(map[string]interface{}); !secretRef && bot.AppSecret == "" {
|
|
||||||
return feishuConfigError("accounts." + bot.AccountID + ".appSecret")
|
|
||||||
}
|
|
||||||
entry["appId"], entry["name"], entry["enabled"], entry["dmPolicy"] = bot.AppID, bot.Name, bot.Enabled, bot.DmPolicy
|
|
||||||
if bot.AppSecret != "" {
|
|
||||||
entry["appSecret"] = bot.AppSecret
|
|
||||||
}
|
|
||||||
switch bot.DmPolicy {
|
|
||||||
case "open":
|
|
||||||
entry["allowFrom"] = []string{"*"}
|
|
||||||
case "allowlist":
|
|
||||||
entry["allowFrom"] = bot.AllowFrom
|
|
||||||
default:
|
|
||||||
if len(bot.AllowFrom) > 0 {
|
|
||||||
entry["allowFrom"] = bot.AllowFrom
|
|
||||||
} else {
|
|
||||||
entry["allowFrom"] = []string{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accounts[bot.AccountID] = entry
|
|
||||||
}
|
|
||||||
for id := range accounts {
|
|
||||||
if !seen[id] {
|
|
||||||
delete(accounts, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !seen["default"] {
|
|
||||||
delete(f, "appId")
|
|
||||||
delete(f, "appSecret")
|
|
||||||
}
|
|
||||||
if id := extractStringValue(f["defaultAccount"]); id != "" && !seen[id] {
|
|
||||||
return feishuConfigError("defaultAccount")
|
|
||||||
}
|
|
||||||
if len(accounts) > 0 {
|
|
||||||
f["accounts"] = accounts
|
|
||||||
} else {
|
|
||||||
delete(f, "accounts")
|
|
||||||
}
|
|
||||||
f["enabled"], f["groupSessionScope"], f["groupPolicy"] = req.Enabled, req.GroupSessionScope, req.GroupPolicy
|
|
||||||
if req.RequireMention == "open" {
|
|
||||||
delete(f, "requireMention")
|
|
||||||
} else {
|
|
||||||
f["requireMention"] = req.RequireMention == "true"
|
|
||||||
}
|
|
||||||
if req.GroupPolicy == "allowlist" {
|
|
||||||
f["groupAllowFrom"] = req.GroupAllowFrom
|
|
||||||
} else {
|
|
||||||
delete(f, "groupAllowFrom")
|
|
||||||
}
|
|
||||||
streaming := ensureChildMap(f, "streaming")
|
|
||||||
streaming["mode"] = "off"
|
|
||||||
if req.Streaming {
|
|
||||||
streaming["mode"] = "partial"
|
|
||||||
}
|
|
||||||
selectBundledFeishu(conf, req.Enabled && hasEnabledBots(req.Bots))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write beside the original so validation and rename preserve the mounted filesystem and owner.
|
|
||||||
func stageFeishuConfig(configPath string, payload []byte) (string, error) {
|
|
||||||
info, err := os.Lstat(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return "", feishuConfigError("openclaw.json")
|
|
||||||
}
|
|
||||||
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-*.json")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
name := f.Name()
|
|
||||||
defer func() {
|
|
||||||
f.Close()
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(name)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
||||||
err = f.Chown(int(stat.Uid), int(stat.Gid))
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = f.Chmod(info.Mode().Perm())
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
_, err = f.Write(payload)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = f.Sync()
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = f.Close()
|
|
||||||
}
|
|
||||||
return name, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateFeishuConfig(container, configPath string) error {
|
|
||||||
out, err := cmd.RunDockerExecWithStdout(time.Minute, container, "env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath), "openclaw", "config", "validate", "--json")
|
|
||||||
return feishuValidationResult(out, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceFeishuConfig(configPath string, expected, payload []byte) error {
|
|
||||||
tmp, err := stageFeishuConfig(configPath, payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(tmp)
|
|
||||||
current, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Equal(current, expected) {
|
|
||||||
return buserr.New("ErrFeishuConfigChanged")
|
|
||||||
}
|
|
||||||
return os.Rename(tmp, configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AgentService) SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error {
|
|
||||||
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
started := false
|
|
||||||
defer func() {
|
|
||||||
if !started {
|
|
||||||
feishuSwitches.Delete(agent.ConfigPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !hasBundledFeishu(install.ContainerName) {
|
|
||||||
return buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
conf, err := readOpenclawConfig(agent.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !legacy {
|
|
||||||
return buserr.New("ErrFeishuAlreadySwitched")
|
|
||||||
}
|
|
||||||
if err := prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switchTask, err := task.NewTaskWithOps("Feishu", task.TaskUpdate, task.TaskScopeAI, req.TaskID, req.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switchTask.AddSubTaskWithAlias("AgentFeishuSwitch", func(t *task.Task) error { return a.runFeishuSwitch(agent, install, req, t) }, nil)
|
|
||||||
started = true
|
|
||||||
go func() { defer feishuSwitches.Delete(agent.ConfigPath); executeAgentPluginTask(switchTask) }()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AgentService) runFeishuSwitch(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuSwitchReq, t *task.Task) (result error) {
|
|
||||||
original, conf, err := readFeishuConfigBytes(agent.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !legacy {
|
|
||||||
return buserr.New("ErrFeishuAlreadySwitched")
|
|
||||||
}
|
|
||||||
image, err := feishuContainerImage(install.ContainerName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
image, err = bundledFeishuImage(image)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payload, err := json.MarshalIndent(conf, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
recovery, err := feishuRecoveryPayload(payload, configured)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Validate both candidates without invoking Doctor or mutating retained state.
|
|
||||||
for _, candidate := range [][]byte{payload, recovery} {
|
|
||||||
staged, stageErr := stageFeishuConfig(agent.ConfigPath, candidate)
|
|
||||||
if stageErr != nil {
|
|
||||||
return stageErr
|
|
||||||
}
|
|
||||||
validateErr := validateFeishuImageConfig(image, staged)
|
|
||||||
os.Remove(staged)
|
|
||||||
if validateErr != nil {
|
|
||||||
return validateErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err = compose.Stop(install.GetComposePath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
replaced := false
|
|
||||||
defer func() {
|
|
||||||
if !replaced {
|
|
||||||
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
|
|
||||||
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, stopErr := compose.Stop(install.GetComposePath()); stopErr != nil {
|
|
||||||
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", stopErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if restoreErr := replaceFeishuConfig(agent.ConfigPath, payload, recovery); restoreErr != nil {
|
|
||||||
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", restoreErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
|
|
||||||
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if healthErr := verifyFeishuActivation(install.ContainerName, false); healthErr != nil {
|
|
||||||
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", healthErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
|
|
||||||
}()
|
|
||||||
current, err := os.ReadFile(agent.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Equal(current, original) {
|
|
||||||
return buserr.New("ErrFeishuConfigChanged")
|
|
||||||
}
|
|
||||||
backup, err := backupFeishuConfig(agent.ConfigPath, original)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.Log(i18n.GetWithName("AgentFeishuBackup", filepath.Base(backup)))
|
|
||||||
if err = replaceFeishuConfig(agent.ConfigPath, original, payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
replaced = true
|
|
||||||
if _, err = compose.Operate(install.GetComposePath(), "start"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
enabled := configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
|
|
||||||
if err = verifyFeishuActivation(install.ContainerName, enabled); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if enabled {
|
|
||||||
probeFeishuConnection(install.ContainerName, t)
|
|
||||||
}
|
|
||||||
if configured {
|
|
||||||
t.Log(i18n.GetMsgByKey("AgentFeishuVerifyMessages"))
|
|
||||||
} else {
|
|
||||||
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveBundledFeishuConfig(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuConfigUpdateReq) error {
|
|
||||||
if !isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer feishuSwitches.Delete(agent.ConfigPath)
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !hasBundledFeishu(install.ContainerName) {
|
|
||||||
return buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
original, err := os.ReadFile(agent.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var conf map[string]interface{}
|
|
||||||
if err = json.Unmarshal(original, &conf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if hasLegacyFeishu(conf, install.GetPath()) {
|
|
||||||
return buserr.New("ErrFeishuSwitchRequired")
|
|
||||||
}
|
|
||||||
if err = setBundledFeishuConfig(conf, req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payload, err := json.MarshalIndent(conf, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
staged, err := stageFeishuConfig(agent.ConfigPath, payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(staged)
|
|
||||||
if err = validateFeishuConfig(install.ContainerName, staged); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return replaceFeishuConfig(agent.ConfigPath, original, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuProbeOK(output string) bool {
|
|
||||||
raw, err := extractEmbeddedJSON(output)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var status struct {
|
|
||||||
ChannelAccounts map[string][]struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Configured bool `json:"configured"`
|
|
||||||
Running bool `json:"running"`
|
|
||||||
Probe struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
} `json:"probe"`
|
|
||||||
} `json:"channelAccounts"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &status) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
accounts, ok := status.ChannelAccounts["feishu"]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, account := range accounts {
|
|
||||||
if account.Enabled && (!account.Configured || !account.Running || !account.Probe.OK) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuGatewayReady(output string, enabled bool) bool {
|
|
||||||
raw, err := extractEmbeddedJSON(output)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var health struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Plugins struct {
|
|
||||||
Loaded []string `json:"loaded"`
|
|
||||||
} `json:"plugins"`
|
|
||||||
}
|
|
||||||
return json.Unmarshal(raw, &health) == nil && health.OK && !slices.Contains(health.Plugins.Loaded, "openclaw-lark") && (!enabled || slices.Contains(health.Plugins.Loaded, "feishu"))
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Inspect a stopped disposable container; its entrypoint never touches retained state.
|
|
||||||
func bundledFeishuImage(image string) (string, error) {
|
|
||||||
cli, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
info, _, err := cli.ImageInspectWithRaw(ctx, image)
|
|
||||||
if err != nil {
|
|
||||||
return "", buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
created, err := cli.ContainerCreate(ctx, &container.Config{Image: info.ID, Entrypoint: []string{"/bin/true"}}, &container.HostConfig{NetworkMode: "none"}, nil, nil, "")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
cleanup, done := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer done()
|
|
||||||
_ = cli.ContainerRemove(cleanup, created.ID, container.RemoveOptions{Force: true, RemoveVolumes: true})
|
|
||||||
}()
|
|
||||||
if _, err = cli.ContainerStatPath(ctx, created.ID, "/app/.1panel-feishu-bundled-v1"); err != nil {
|
|
||||||
return "", buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
return info.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuContainerImage(name string) (string, error) {
|
|
||||||
cli, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
info, err := cli.ContainerInspect(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return info.Image, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The original state is read-only and the normal Doctor entrypoint is bypassed.
|
|
||||||
func validateFeishuImageConfig(image, configPath string) error {
|
|
||||||
info, err := os.Stat(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
uid, gid := uint32(1000), uint32(1000)
|
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
||||||
uid, gid = stat.Uid, stat.Gid
|
|
||||||
}
|
|
||||||
user := fmt.Sprintf("%d:%d", uid, gid)
|
|
||||||
tmpfs := fmt.Sprintf("/tmp:rw,mode=0700,uid=%d,gid=%d", uid, gid)
|
|
||||||
name := "1panel-feishu-" + strings.TrimSuffix(filepath.Base(configPath), ".json")
|
|
||||||
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
|
|
||||||
defer func() { _ = cmd.NewCommandMgr(cmd.WithTimeout(15*time.Second)).Run("docker", "rm", "-f", "-v", name) }()
|
|
||||||
out, runErr := mgr.RunWithStdout("docker", "run", "--name", name, "--rm", "--network", "none", "--read-only", "--user", user,
|
|
||||||
"--tmpfs", tmpfs,
|
|
||||||
"--mount", "type=bind,src="+filepath.Dir(configPath)+",dst=/home/node/.openclaw,readonly",
|
|
||||||
"--env", "HOME=/home/node", "--env", "TMPDIR=/tmp", "--env", "OPENCLAW_STATE_DIR=/tmp/state",
|
|
||||||
"--env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath),
|
|
||||||
"--entrypoint", "sh", image, "-c", "mkdir -m 700 /tmp/state && ln -s /home/node/.openclaw/extensions /tmp/state/extensions && exec node /app/openclaw.mjs config validate --json")
|
|
||||||
return feishuValidationResult(out, runErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuValidationResult(out string, runErr error) error {
|
|
||||||
raw, _ := extractEmbeddedJSON(out)
|
|
||||||
var result struct {
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
Issues []struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
} `json:"issues"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &result) == nil && result.Valid && runErr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fields := []string{}
|
|
||||||
for _, issue := range result.Issues {
|
|
||||||
if issue.Path != "" {
|
|
||||||
fields = append(fields, issue.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(fields) == 0 {
|
|
||||||
fields = append(fields, "openclaw.json")
|
|
||||||
}
|
|
||||||
return feishuConfigError(strings.Join(fields, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func backupFeishuConfig(configPath string, original []byte) (string, error) {
|
|
||||||
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-backup-*.json")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err = f.Write(original); err == nil {
|
|
||||||
err = f.Sync()
|
|
||||||
}
|
|
||||||
closeErr := f.Close()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return "", closeErr
|
|
||||||
}
|
|
||||||
return f.Name(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doctor can spend 94 seconds waiting for the previous container's owner lease.
|
|
||||||
func waitOpenclawReady(name string) error {
|
|
||||||
script := `const fs=require('fs');const c=JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));const tls=c.gateway?.tls?.enabled===true;const h=require(tls?'https':'http');function check(p){const r=h.get({hostname:'127.0.0.1',port:c.gateway?.port||18789,path:p,rejectUnauthorized:false,timeout:3000},s=>{s.resume();if(s.statusCode===404&&p==='/readyz')return check('/healthz');process.exitCode=s.statusCode===200?0:1});r.on('timeout',()=>r.destroy());r.on('error',()=>process.exitCode=1)}check('/readyz')`
|
|
||||||
deadline := time.Now().Add(4 * time.Minute)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if cmd.RunDockerExec(8*time.Second, name, "node", "-e", script) == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
}
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyFeishuActivation(name string, enabled bool) error {
|
|
||||||
if err := waitOpenclawReady(name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
output, err := cmd.RunDockerExecWithStdout(time.Minute, name, "openclaw", "plugins", "list", "--json")
|
|
||||||
if err != nil {
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
raw, _ := extractEmbeddedJSON(output)
|
|
||||||
var result struct {
|
|
||||||
Plugins []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Origin string `json:"origin"`
|
|
||||||
} `json:"plugins"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &result) != nil {
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, p := range result.Plugins {
|
|
||||||
if p.ID == "openclaw-lark" && p.Status != "disabled" {
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
if p.ID == "feishu" && p.Origin == "bundled" && (p.Status == "loaded" || (!enabled && p.Status == "disabled")) {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
health, err := cmd.RunDockerExecWithStdout(20*time.Second, name, "openclaw", "gateway", "health", "--json")
|
|
||||||
if err != nil || !feishuGatewayReady(health, enabled) {
|
|
||||||
return buserr.New("ErrFeishuActivation")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFeishuConfigBytes(file string) ([]byte, map[string]interface{}, error) {
|
|
||||||
info, err := os.Lstat(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return nil, nil, feishuConfigError("openclaw.json")
|
|
||||||
}
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
raw, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var conf map[string]interface{}
|
|
||||||
if err = json.Unmarshal(raw, &conf); err != nil || conf == nil {
|
|
||||||
return nil, nil, feishuConfigError("openclaw.json")
|
|
||||||
}
|
|
||||||
return raw, conf, nil
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hermesWeixinLoginScript = `import asyncio
|
const hermesWeixinLoginScript = `import asyncio
|
||||||
try:
|
from gateway.platforms.weixin import qr_login
|
||||||
from plugins.platforms.weixin.adapter import qr_login
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
|
|
||||||
raise
|
|
||||||
from gateway.platforms.weixin import qr_login
|
|
||||||
from hermes_cli.config import save_env_value
|
from hermes_cli.config import save_env_value
|
||||||
|
|
||||||
creds = asyncio.run(qr_login('/opt/data'))
|
creds = asyncio.run(qr_login('/opt/data'))
|
||||||
|
print('RESULT =', creds)
|
||||||
|
|
||||||
if not creds:
|
if not creds:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
|
|
||||||
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
|
|
||||||
return nil, fmt.Errorf("invalid local model installation")
|
|
||||||
}
|
|
||||||
agentAccountMutationMu.Lock()
|
|
||||||
defer agentAccountMutationMu.Unlock()
|
|
||||||
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
|
|
||||||
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
|
|
||||||
return nil, fmt.Errorf("model account belongs to another installation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
if !createIfMissing {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
name := install.Name
|
|
||||||
for suffix := 1; ; suffix++ {
|
|
||||||
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(existing) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
name = fmt.Sprintf("%s-%d", install.Name, suffix)
|
|
||||||
}
|
|
||||||
apiType, apiKey := "openai-completions", "vllm-local"
|
|
||||||
if install.App.Key == "ollama" {
|
|
||||||
apiType, apiKey = "openai-responses", "ollama-local"
|
|
||||||
}
|
|
||||||
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
|
|
||||||
}
|
|
||||||
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
|
|
||||||
account.AppInstallID = install.ID
|
|
||||||
if updateBaseURL {
|
|
||||||
account.BaseURL = baseURL
|
|
||||||
}
|
|
||||||
rows := make([]model.AgentAccountModel, 0, len(models))
|
|
||||||
verifyModelExists := account.VerifyModel == ""
|
|
||||||
for _, item := range models {
|
|
||||||
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
|
|
||||||
if item.ID == account.VerifyModel {
|
|
||||||
verifyModelExists = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !verifyModelExists {
|
|
||||||
account.VerifyModel = ""
|
|
||||||
accountChanged = true
|
|
||||||
}
|
|
||||||
var previous []model.AgentAccountModel
|
|
||||||
if account.ID != 0 {
|
|
||||||
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
modelsChanged := len(previous) != len(rows)
|
|
||||||
if !modelsChanged {
|
|
||||||
for i := range rows {
|
|
||||||
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
|
|
||||||
modelsChanged = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if modelsChanged {
|
|
||||||
err = agentAccountRepo.SaveWithModels(account, rows)
|
|
||||||
} else if accountChanged {
|
|
||||||
err = agentAccountRepo.Save(account)
|
|
||||||
} else {
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
terminalai.InvalidateTerminalRuntimeCache()
|
|
||||||
terminalai.InvalidateFileAIRuntimeCache()
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SyncOllamaModelAccount(createIfMissing bool) error {
|
|
||||||
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !install.SyncModelAccount {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
|
|
||||||
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items := make([]dto.AgentAccountModel, 0, len(models))
|
|
||||||
for _, item := range models {
|
|
||||||
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
|
|
||||||
}
|
|
||||||
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateOllamaModelPool(modelName string, remove bool) error {
|
|
||||||
install, err := appInstallRepo.LoadInstallAppByKeyAndName("ollama", "")
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !install.SyncModelAccount {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
agentAccountMutationMu.Lock()
|
|
||||||
defer agentAccountMutationMu.Unlock()
|
|
||||||
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
models, err := loadAgentAccountModels(account)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if remove {
|
|
||||||
for _, item := range models {
|
|
||||||
if !sameProviderModelID("ollama", item.ID, modelName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := agentAccountModelRepo.DeleteByID(item.RecordID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sameProviderModelID("ollama", account.VerifyModel, modelName) {
|
|
||||||
account.VerifyModel = ""
|
|
||||||
if err := agentAccountRepo.Save(account); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := compactPersistedAgentAccountModelSortOrder(account.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if _, exists := findAgentAccountModelForProvider("ollama", models, modelName); exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
record := &model.AgentAccountModel{AccountID: account.ID, Model: modelName, Name: modelName, SortOrder: len(models) + 1}
|
|
||||||
if err := agentAccountModelRepo.Create(record); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
terminalai.InvalidateTerminalRuntimeCache()
|
|
||||||
terminalai.InvalidateFileAIRuntimeCache()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
|
|
||||||
addressType := install.ModelAccountBaseURLType
|
|
||||||
if addressType == "" {
|
|
||||||
addressType = "container"
|
|
||||||
}
|
|
||||||
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return addressType, install.ModelAccountBaseURL, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if addressType != "custom" {
|
|
||||||
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
|
|
||||||
addressType = "custom"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return addressType, account.BaseURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CleanupLocalModelAccount(installID uint) {
|
|
||||||
if installID == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
|
|
||||||
if err != nil {
|
|
||||||
global.LOG.Errorf("load local model accounts: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, account := range accounts {
|
|
||||||
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
|
|
||||||
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
|
|
||||||
switch addressType {
|
|
||||||
case "custom":
|
|
||||||
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
|
|
||||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
|
||||||
return "", fmt.Errorf("model account base URL must start with http:// or https://")
|
|
||||||
}
|
|
||||||
return baseURL, nil
|
|
||||||
case "localhost":
|
|
||||||
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
|
|
||||||
case "systemIP":
|
|
||||||
systemIP, err := settingRepo.GetValueByKey("SystemIP")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
systemIP = strings.TrimSpace(systemIP)
|
|
||||||
if systemIP == "" {
|
|
||||||
systemIP = "127.0.0.1"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteRemoteOllamaModel(account *model.AgentAccount, modelName string) error {
|
|
||||||
body, err := json.Marshal(map[string]string{"model": modelName})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
baseURL := strings.TrimSuffix(strings.TrimRight(account.BaseURL, "/"), "/v1")
|
|
||||||
req, err := http.NewRequest(http.MethodDelete, baseURL+"/api/delete", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+account.APIKey)
|
|
||||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
||||||
return fmt.Errorf("delete Ollama model failed: %s", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveExistingOllamaModel(account *model.AgentAccount, modelID string) (string, error) {
|
|
||||||
available, err := providercatalog.DiscoverModels(account.BaseURL, account.APIKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", buserr.WithErr("ErrAgentAccountUnavailable", err)
|
|
||||||
}
|
|
||||||
for _, id := range available {
|
|
||||||
if sameProviderModelID("ollama", id, modelID) {
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", buserr.New("ErrRecordNotFound")
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMiniMaxOpenclawModelMetadata(t *testing.T) {
|
|
||||||
for _, provider := range []string{"minimax", "custom", "bailian-coding-plan"} {
|
|
||||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
|
||||||
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "saved-custom"} {
|
|
||||||
t.Run(provider+"/"+protocol+"/"+id, func(t *testing.T) {
|
|
||||||
account := &model.AgentAccount{Provider: provider, APIType: protocol, BaseURL: "https://example.test/v1", APIKey: "test-key"}
|
|
||||||
_, entry, _, _, err := buildOpenclawAccountModelConfig(account, dto.AgentAccountModel{ID: id, Name: "Saved name"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(entry)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var actual modelEntry
|
|
||||||
if err := json.Unmarshal(payload, &actual); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if actual.ID != id || actual.Name != "Saved name" {
|
|
||||||
t.Fatalf("changed saved identity: %s", payload)
|
|
||||||
}
|
|
||||||
if provider == "minimax" && id == "MiniMax-M3.1-Flash" {
|
|
||||||
if actual.ContextWindow != 1000000 || actual.MaxTokens != 524288 || !reflect.DeepEqual(actual.Input, []string{"text", "image"}) {
|
|
||||||
t.Fatalf("incorrect direct Flash metadata: %s", payload)
|
|
||||||
}
|
|
||||||
} else if actual.ContextWindow != 0 || actual.MaxTokens != 0 {
|
|
||||||
t.Fatalf("changed existing/gateway limits: %s", payload)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxPreservesSavedOpenclawMetadata(t *testing.T) {
|
|
||||||
current := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
|
|
||||||
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text"}, ContextWindow: 32000, MaxTokens: 4096}}},
|
|
||||||
}}
|
|
||||||
conf := map[string]interface{}{}
|
|
||||||
if err := applyOpenclawModelsConfig(conf, current); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
next := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
|
|
||||||
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text", "image"}, ContextWindow: 1000000, MaxTokens: 524288}}},
|
|
||||||
}}
|
|
||||||
preserveOpenclawModelMetadata(conf, next)
|
|
||||||
if !reflect.DeepEqual(next.Providers["minimax"].Models, current.Providers["minimax"].Models) {
|
|
||||||
t.Fatalf("saved metadata changed: %+v", next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxDefaultAccountRequest(t *testing.T) {
|
|
||||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
|
||||||
account := &model.AgentAccount{Provider: "minimax", APIType: protocol}
|
|
||||||
models, err := buildInitialAgentAccountModels(account, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
selected, err := resolveAgentAccountVerifyModel("minimax", "", models)
|
|
||||||
if err != nil || selected != "MiniMax-M3.1-Flash" || len(models) != 2 || models[1].ID != "MiniMax-M3" {
|
|
||||||
t.Fatalf("unexpected default: %s, %+v, %v", selected, models, err)
|
|
||||||
}
|
|
||||||
request := providercatalog.BuildVerifyRequest("minimax", protocol, "", "https://example.test", "test-key", selected)
|
|
||||||
var body map[string]interface{}
|
|
||||||
if err := json.Unmarshal(request.Body, &body); err != nil || body["model"] != selected {
|
|
||||||
t.Fatalf("default request: %s, %v", request.Body, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxHermesConfiguration(t *testing.T) {
|
|
||||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
|
||||||
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "my-custom-model"} {
|
|
||||||
dir := t.TempDir()
|
|
||||||
account := &model.AgentAccount{Provider: "minimax", APIType: protocol, BaseURL: "https://api.minimaxi.com/anthropic", APIKey: "test-key"}
|
|
||||||
if err := writeHermesConfig(dir, account, id, ""); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
conf, err := readHermesConfig(filepath.Join(dir, "config.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if conf.Model.Default != id || conf.Model.Provider != "minimax-cn" || conf.Model.BaseURL != account.BaseURL {
|
|
||||||
t.Fatalf("changed Hermes selection: %+v", conf.Model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiniMaxExplicitAccountModels(t *testing.T) {
|
|
||||||
for _, provider := range []string{"minimax", "bailian-coding-plan", "custom"} {
|
|
||||||
account := &model.AgentAccount{Provider: provider, APIType: "openai-completions"}
|
|
||||||
requested := []dto.AgentAccountModel{{ID: "MiniMax-M2.5", Name: "Saved legacy"}, {ID: "my-custom-model", Name: "Saved custom"}}
|
|
||||||
actual, err := buildInitialAgentAccountModels(account, requested)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(actual, requested) {
|
|
||||||
t.Fatalf("changed explicit %s models: %+v", provider, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
)
|
|
||||||
|
|
||||||
var deepSeekUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|
||||||
var deepSeekHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
|
||||||
|
|
||||||
func isDeepSeekAccessHost(value string) bool {
|
|
||||||
if ip := net.ParseIP(value); ip != nil {
|
|
||||||
return ip.To4() != nil && !strings.Contains(value, ":")
|
|
||||||
}
|
|
||||||
if value == "" || len(value) > 253 || strings.Trim(value, "0123456789.") == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, label := range strings.Split(value, ".") {
|
|
||||||
if !deepSeekHostLabelPattern.MatchString(label) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateNativeAgentCreate(req dto.AgentCreateReq) error {
|
|
||||||
if req.AgentType != constant.AppDeepSeekHarness && req.AgentType != constant.AppOctop {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.DashboardUsername) == "" || strings.ContainsAny(req.DashboardUsername, "\r\n\x00") {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(req.DashboardPassword, "\r\n\x00") || req.DashboardPassword != strings.TrimSpace(req.DashboardPassword) {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
|
||||||
}
|
|
||||||
if req.AgentType == constant.AppDeepSeekHarness {
|
|
||||||
if !isDeepSeekAccessHost(req.AccessHost) {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "accessHost", nil)
|
|
||||||
}
|
|
||||||
if !deepSeekUsernamePattern.MatchString(req.DashboardUsername) {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(req.DashboardPassword) < 12 {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
hasLetter, hasDigit := false, false
|
|
||||||
for _, char := range req.DashboardPassword {
|
|
||||||
hasLetter = hasLetter || unicode.IsLetter(char)
|
|
||||||
hasDigit = hasDigit || unicode.IsDigit(char)
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(req.DashboardPassword) < 8 || !hasLetter || !hasDigit {
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
|
||||||
}
|
|
||||||
switch strings.ToLower(req.DashboardPassword) {
|
|
||||||
case "password", "password1", "password12", "password123", "12345678", "123456789", "qwerty123",
|
|
||||||
"admin123", "welcome1", "letmein1", "changeme1", "octop123", "abc12345", "iloveyou1":
|
|
||||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -107,9 +107,6 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isOpenclawBundledFeishuVersion(install.Version) && (strings.TrimSpace(req.Package) == "@larksuite/openclaw-lark" || strings.TrimSpace(req.Package) == "@openclaw/feishu") {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -135,9 +132,6 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isOpenclawBundledFeishuVersion(install.Version) && (req.PluginID == "openclaw-lark" || req.PluginID == "feishu") {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
|
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
|
||||||
resolvedAPIKey := strings.TrimSpace(apiKey)
|
resolvedAPIKey := strings.TrimSpace(apiKey)
|
||||||
resolvedAPIType := strings.TrimSpace(apiType)
|
resolvedAPIType := strings.TrimSpace(apiType)
|
||||||
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
|
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
|
||||||
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
|
|||||||
return resolvedAgentAccountInput{}, err
|
return resolvedAgentAccountInput{}, err
|
||||||
}
|
}
|
||||||
modelID = strings.TrimSpace(modelID)
|
modelID = strings.TrimSpace(modelID)
|
||||||
if modelID == "" && !allowEmptyModel {
|
if modelID == "" {
|
||||||
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
|
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
|
||||||
}
|
}
|
||||||
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
|
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
|
||||||
@@ -148,10 +148,6 @@ func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
|
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
|
||||||
if _, busy := feishuSwitches.LoadOrStore(configPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer feishuSwitches.Delete(configPath)
|
|
||||||
ensureGatewaySecurityDefaults(conf)
|
ensureGatewaySecurityDefaults(conf)
|
||||||
ensureOpenclawUpdateDefaults(conf)
|
ensureOpenclawUpdateDefaults(conf)
|
||||||
payload, err := json.MarshalIndent(conf, "", " ")
|
payload, err := json.MarshalIndent(conf, "", " ")
|
||||||
@@ -419,15 +415,6 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
|
|||||||
} else {
|
} else {
|
||||||
item.WebUIPort = appInstall.HttpPort
|
item.WebUIPort = appInstall.HttpPort
|
||||||
}
|
}
|
||||||
if agentType == constant.AppDeepSeekHarness {
|
|
||||||
item.WebUIPort = appInstall.HttpsPort
|
|
||||||
if values, err := readAgentEnvMap(appInstall.GetEnvPath()); err == nil {
|
|
||||||
accessHost := values["HTTPS_ACCESS_HOST"]
|
|
||||||
if isDeepSeekAccessHost(accessHost) {
|
|
||||||
item.AccessHost = accessHost
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item.Path = appInstall.GetPath()
|
item.Path = appInstall.GetPath()
|
||||||
item.Status = appInstall.Status
|
item.Status = appInstall.Status
|
||||||
item.Message = appInstall.Message
|
item.Message = appInstall.Message
|
||||||
@@ -455,8 +442,7 @@ func localizedAgentProviderName(provider string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAgentAppKey(appKey string) bool {
|
func isAgentAppKey(appKey string) bool {
|
||||||
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent ||
|
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
|
||||||
appKey == constant.AppDeepSeekHarness || appKey == constant.AppOctop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isOpenclawLegacyHTTPVersion(version string) bool {
|
func isOpenclawLegacyHTTPVersion(version string) bool {
|
||||||
@@ -1267,12 +1253,7 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", modelEntry{}, "", modelProvider{}, err
|
return "", modelEntry{}, "", modelProvider{}, err
|
||||||
}
|
}
|
||||||
entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
|
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{
|
||||||
if account.Provider == "minimax" && providerPatch.ModelID == "MiniMax-M3.1-Flash" {
|
|
||||||
entry.ContextWindow = 1000000
|
|
||||||
entry.MaxTokens = 524288
|
|
||||||
}
|
|
||||||
return providerPatch.PrimaryModel, entry, providerPatch.ProviderKey, modelProvider{
|
|
||||||
ApiKey: providerPatch.APIKey,
|
ApiKey: providerPatch.APIKey,
|
||||||
BaseUrl: providerPatch.BaseURL,
|
BaseUrl: providerPatch.BaseURL,
|
||||||
Api: providerPatch.APIType,
|
Api: providerPatch.APIType,
|
||||||
@@ -1517,14 +1498,6 @@ func sameProviderModelID(provider, left, right string) bool {
|
|||||||
}
|
}
|
||||||
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
|
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
|
||||||
rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
|
rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
|
||||||
if provider == "ollama" {
|
|
||||||
if leftComparable != "" && !strings.Contains(path.Base(leftComparable), ":") {
|
|
||||||
leftComparable += ":latest"
|
|
||||||
}
|
|
||||||
if rightComparable != "" && !strings.Contains(path.Base(rightComparable), ":") {
|
|
||||||
rightComparable += ":latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return leftComparable != "" && leftComparable == rightComparable
|
return leftComparable != "" && leftComparable == rightComparable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1668,10 +1641,6 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
|
|||||||
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
|
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
|
||||||
case constant.AppCopaw:
|
case constant.AppCopaw:
|
||||||
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
|
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
|
||||||
case constant.AppDeepSeekHarness:
|
|
||||||
return "DSH_AUTH_USERNAME", "DSH_AUTH_PASSWORD", true
|
|
||||||
case constant.AppOctop:
|
|
||||||
return "OCTOP_ADMIN_USERNAME", "OCTOP_DEFAULT_PASSWORD", true
|
|
||||||
default:
|
default:
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-123
@@ -8,7 +8,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,15 +28,13 @@ import (
|
|||||||
|
|
||||||
type AIToolService struct{}
|
type AIToolService struct{}
|
||||||
|
|
||||||
var ollamaModelMu sync.Mutex
|
|
||||||
|
|
||||||
type IAIToolService interface {
|
type IAIToolService interface {
|
||||||
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
|
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
|
||||||
Create(req dto.OllamaModelName) error
|
Create(req dto.OllamaModelName) error
|
||||||
Close(name string) error
|
Close(name string) error
|
||||||
Recreate(req dto.OllamaModelName) error
|
Recreate(req dto.OllamaModelName) error
|
||||||
Delete(req dto.ForceDelete) error
|
Delete(req dto.ForceDelete) error
|
||||||
SyncAccount(req dto.OllamaModelAccountSync) error
|
Sync() ([]dto.OllamaModelDropList, error)
|
||||||
LoadDetail(name string) (string, error)
|
LoadDetail(name string) (string, error)
|
||||||
BindDomain(req dto.OllamaBindDomain) error
|
BindDomain(req dto.OllamaBindDomain) error
|
||||||
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
|
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
|
||||||
@@ -49,7 +46,6 @@ func NewIAIToolService() IAIToolService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
|
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
|
||||||
syncErr := syncOllamaModels()
|
|
||||||
var options []repo.DBOption
|
var options []repo.DBOption
|
||||||
if len(req.Info) != 0 {
|
if len(req.Info) != 0 {
|
||||||
options = append(options, repo.WithByLikeName(req.Info))
|
options = append(options, repo.WithByLikeName(req.Info))
|
||||||
@@ -64,11 +60,6 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
|
|||||||
if err := copier.Copy(&item, &itemModel); err != nil {
|
if err := copier.Copy(&item, &itemModel); err != nil {
|
||||||
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
||||||
}
|
}
|
||||||
if syncErr != nil && item.Status != constant.StatusWaiting {
|
|
||||||
item.Status = constant.StatusUnknown
|
|
||||||
item.Message = syncErr.Error()
|
|
||||||
item.Size = ""
|
|
||||||
}
|
|
||||||
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
|
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
|
||||||
if len(taskModel.ID) != 0 {
|
if len(taskModel.ID) != 0 {
|
||||||
item.LogFileExist = true
|
item.LogFileExist = true
|
||||||
@@ -94,8 +85,6 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
if cmd.CheckIllegal(req.Name) {
|
if cmd.CheckIllegal(req.Name) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
@@ -117,7 +106,6 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
|||||||
}
|
}
|
||||||
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, info.ID)
|
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, info.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
|
||||||
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
|
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,13 +115,12 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
|||||||
}, nil)
|
}, nil)
|
||||||
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
|
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
|
||||||
itemSize, err := loadModelSize(info.Name, containerName)
|
itemSize, err := loadModelSize(info.Name, containerName)
|
||||||
if err != nil {
|
if len(itemSize) != 0 {
|
||||||
return err
|
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
|
||||||
|
} else {
|
||||||
|
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||||
}
|
}
|
||||||
return aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
|
return nil
|
||||||
}, nil)
|
|
||||||
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
|
|
||||||
return updateOllamaModelPool(info.Name, false)
|
|
||||||
}, nil)
|
}, nil)
|
||||||
if err := taskItem.Execute(); err != nil {
|
if err := taskItem.Execute(); err != nil {
|
||||||
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||||
@@ -157,8 +144,6 @@ func (u *AIToolService) Close(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
if cmd.CheckIllegal(req.Name) {
|
if cmd.CheckIllegal(req.Name) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
@@ -166,9 +151,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
|||||||
if modelInfo.ID == 0 {
|
if modelInfo.ID == 0 {
|
||||||
return buserr.New("ErrRecordNotFound")
|
return buserr.New("ErrRecordNotFound")
|
||||||
}
|
}
|
||||||
if modelInfo.Status == constant.StatusWaiting {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
containerName, err := LoadContainerName()
|
containerName, err := LoadContainerName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -178,7 +160,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
|||||||
}
|
}
|
||||||
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, modelInfo.ID)
|
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, modelInfo.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
|
||||||
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
|
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -188,13 +169,12 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
|||||||
}, nil)
|
}, nil)
|
||||||
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
|
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
|
||||||
itemSize, err := loadModelSize(modelInfo.Name, containerName)
|
itemSize, err := loadModelSize(modelInfo.Name, containerName)
|
||||||
if err != nil {
|
if len(itemSize) != 0 {
|
||||||
return err
|
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
|
||||||
|
} else {
|
||||||
|
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||||
}
|
}
|
||||||
return aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
|
return nil
|
||||||
}, nil)
|
|
||||||
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
|
|
||||||
return updateOllamaModelPool(modelInfo.Name, false)
|
|
||||||
}, nil)
|
}, nil)
|
||||||
if err := taskItem.Execute(); err != nil {
|
if err := taskItem.Execute(); err != nil {
|
||||||
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||||
@@ -204,8 +184,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
|
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
|
||||||
if len(ollamaList) == 0 {
|
if len(ollamaList) == 0 {
|
||||||
return buserr.New("ErrRecordNotFound")
|
return buserr.New("ErrRecordNotFound")
|
||||||
@@ -220,37 +198,59 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
|||||||
return fmt.Errorf("handle ollama rm %s failed, %v", item.Name, err)
|
return fmt.Errorf("handle ollama rm %s failed, %v", item.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := aiRepo.Delete(repo.WithByID(item.ID)); err != nil {
|
_ = aiRepo.Delete(repo.WithByID(item.ID))
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := updateOllamaModelPool(item.Name, true); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
|
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
|
||||||
_ = os.Remove(logItem)
|
_ = os.Remove(logItem)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
|
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
|
||||||
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
|
containerName, err := LoadContainerName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if install.App.Key != "ollama" {
|
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
|
||||||
return buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
|
var list []model.OllamaModel
|
||||||
return err
|
lines := strings.Split(stdout, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parts[0] == "NAME" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]})
|
||||||
}
|
}
|
||||||
return appInstallRepo.BatchUpdateBy(map[string]interface{}{
|
listInDB, _ := aiRepo.List()
|
||||||
"model_account_base_url_type": req.ModelAccountBaseURLType,
|
var dropList []dto.OllamaModelDropList
|
||||||
"model_account_base_url": baseURL,
|
for _, itemModel := range listInDB {
|
||||||
}, repo.WithByID(install.ID))
|
isExit := false
|
||||||
|
for i := 0; i < len(list); i++ {
|
||||||
|
if list[i].Name == itemModel.Name {
|
||||||
|
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
|
||||||
|
list = append(list[:i], list[(i+1):]...)
|
||||||
|
isExit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isExit && itemModel.Status != constant.StatusWaiting {
|
||||||
|
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
|
||||||
|
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
item.Status = constant.StatusSuccess
|
||||||
|
item.From = "remote"
|
||||||
|
_ = aiRepo.Create(&item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dropList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
|
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
|
||||||
@@ -370,72 +370,6 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncOllamaModels() error {
|
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
containerName, err := LoadContainerName()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
models := make(map[string]string)
|
|
||||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
|
||||||
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
|
|
||||||
return fmt.Errorf("invalid ollama list output: %s", stdout)
|
|
||||||
}
|
|
||||||
for _, line := range lines[1:] {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.Fields(line)
|
|
||||||
if len(parts) < 5 {
|
|
||||||
return fmt.Errorf("invalid ollama model entry: %s", line)
|
|
||||||
}
|
|
||||||
models[parts[0]] = parts[2] + " " + parts[3]
|
|
||||||
}
|
|
||||||
list, err := aiRepo.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
known := make(map[string]bool, len(list))
|
|
||||||
for _, item := range list {
|
|
||||||
name := item.Name
|
|
||||||
if !strings.Contains(path.Base(name), ":") {
|
|
||||||
name += ":latest"
|
|
||||||
}
|
|
||||||
size, exists := models[name]
|
|
||||||
known[name] = true
|
|
||||||
if item.Status == constant.StatusWaiting {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, message := constant.StatusSuccess, ""
|
|
||||||
if !exists {
|
|
||||||
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, message = constant.StatusDeleted, "not exist"
|
|
||||||
}
|
|
||||||
if item.Status != status || item.Size != size || item.Message != message {
|
|
||||||
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name, size := range models {
|
|
||||||
if known[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
|
|
||||||
if err := aiRepo.Create(&item); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadContainerName() (string, error) {
|
func LoadContainerName() (string, error) {
|
||||||
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
|
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -453,12 +387,12 @@ func loadModelSize(name string, containerName string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
lines := strings.Split(stdout, "\n")
|
lines := strings.Split(stdout, "\n")
|
||||||
if !strings.Contains(path.Base(name), ":") {
|
|
||||||
name += ":latest"
|
|
||||||
}
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
|
if !strings.Contains(line, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) < 5 || parts[0] != name {
|
if len(parts) < 5 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return parts[2] + " " + parts[3], nil
|
return parts[2] + " " + parts[3], nil
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
||||||
"github.com/mattn/go-shellwords"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AIBenchmarkSources(kind, selected string) ([]dto.AIBenchmarkSource, error) {
|
|
||||||
if kind != "vllm" && kind != "ollama" {
|
|
||||||
return nil, errors.New("unsupported benchmark source")
|
|
||||||
}
|
|
||||||
selectedID := strings.SplitN(selected, ":", 2)[0]
|
|
||||||
type instance struct {
|
|
||||||
id uint
|
|
||||||
name, container string
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
instances := []instance{}
|
|
||||||
apps, err := appRepo.GetBy(appRepo.WithKey(kind))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, app := range apps {
|
|
||||||
installs, err := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(app.ID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, install := range installs {
|
|
||||||
if selected != "" && strconv.FormatUint(uint64(install.ID), 10) != selectedID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
instances = append(instances, instance{id: install.ID, name: install.Name, container: install.ContainerName, port: install.HttpPort})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results := []dto.AIBenchmarkSource{}
|
|
||||||
if len(instances) == 0 {
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
client, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
if !strings.HasPrefix(client.DaemonHost(), "unix://") {
|
|
||||||
return nil, errors.New("benchmark shortcuts require a Docker daemon on the main node; enter the remote service address manually")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
for _, instance := range instances {
|
|
||||||
base := dto.AIBenchmarkSource{Kind: kind, Name: instance.name, Endpoint: "/v1/chat/completions", Models: []string{}, Status: "unknown"}
|
|
||||||
inspected, err := client.ContainerInspect(ctx, instance.container)
|
|
||||||
if err != nil {
|
|
||||||
base.ID = strconv.FormatUint(uint64(instance.id), 10)
|
|
||||||
base.Message = "container inspection unavailable"
|
|
||||||
results = append(results, base)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inspected.State != nil {
|
|
||||||
base.Status = inspected.State.Status
|
|
||||||
}
|
|
||||||
args := []string{}
|
|
||||||
if inspected.Config != nil {
|
|
||||||
args = append(args, inspected.Config.Entrypoint...)
|
|
||||||
args = append(args, inspected.Config.Cmd...)
|
|
||||||
}
|
|
||||||
if len(args) >= 3 && (args[0] == "sh" || args[0] == "bash" || strings.HasSuffix(args[0], "/sh") || strings.HasSuffix(args[0], "/bash")) && args[1] == "-c" {
|
|
||||||
parser := shellwords.NewParser()
|
|
||||||
parser.ParseEnv = false
|
|
||||||
parser.ParseBacktick = false
|
|
||||||
if parsed, err := parser.Parse(args[2]); err == nil {
|
|
||||||
args = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flags := map[string]string{}
|
|
||||||
for i, arg := range args {
|
|
||||||
if key, value, ok := strings.Cut(arg, "="); ok && strings.HasPrefix(key, "--") {
|
|
||||||
flags[key] = value
|
|
||||||
} else if strings.HasPrefix(arg, "--") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
|
|
||||||
flags[arg] = args[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name := flags["--served-model-name"]; name != "" {
|
|
||||||
base.Models = []string{name}
|
|
||||||
}
|
|
||||||
tokenizer := flags["--tokenizer"]
|
|
||||||
if tokenizer == "" {
|
|
||||||
tokenizer = flags["--model"]
|
|
||||||
for i, arg := range args {
|
|
||||||
if tokenizer == "" && arg == "serve" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
||||||
tokenizer = args[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if kind != "ollama" && tokenizer != "" {
|
|
||||||
if !filepath.IsAbs(tokenizer) {
|
|
||||||
base.Tokenizer = tokenizer
|
|
||||||
} else {
|
|
||||||
longest := 0
|
|
||||||
for _, mount := range inspected.Mounts {
|
|
||||||
relative, err := filepath.Rel(mount.Destination, tokenizer)
|
|
||||||
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && len(mount.Destination) > longest {
|
|
||||||
candidate := filepath.Join(mount.Source, relative)
|
|
||||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
|
||||||
base.Tokenizer = candidate
|
|
||||||
longest = len(mount.Destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ports := map[int]string{}
|
|
||||||
if instance.port > 0 {
|
|
||||||
ports[instance.port] = "127.0.0.1"
|
|
||||||
}
|
|
||||||
if inspected.NetworkSettings != nil {
|
|
||||||
for _, bindings := range inspected.NetworkSettings.Ports {
|
|
||||||
for _, binding := range bindings {
|
|
||||||
port, _ := strconv.Atoi(binding.HostPort)
|
|
||||||
if port > 0 {
|
|
||||||
host := binding.HostIP
|
|
||||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
||||||
host = "127.0.0.1"
|
|
||||||
}
|
|
||||||
ports[port] = host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ports) == 0 && inspected.HostConfig != nil && inspected.HostConfig.NetworkMode == "host" {
|
|
||||||
port, _ := strconv.Atoi(flags["--port"])
|
|
||||||
if port > 0 {
|
|
||||||
ports[port] = "127.0.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ordered := []int{}
|
|
||||||
for port := range ports {
|
|
||||||
ordered = append(ordered, port)
|
|
||||||
}
|
|
||||||
sort.Ints(ordered)
|
|
||||||
if len(ordered) == 0 {
|
|
||||||
ordered = append(ordered, 0)
|
|
||||||
}
|
|
||||||
for _, port := range ordered {
|
|
||||||
source := base
|
|
||||||
source.ID = fmt.Sprintf("%d:%d", instance.id, port)
|
|
||||||
if selected != "" && source.ID != selected {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if port > 0 {
|
|
||||||
source.BaseURL = "http://" + net.JoinHostPort(ports[port], strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
if selected == source.ID && source.BaseURL != "" && source.Status == "running" {
|
|
||||||
endpoint := "/v1/models"
|
|
||||||
if kind == "ollama" {
|
|
||||||
endpoint = "/api/tags"
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.BaseURL+endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
response, err := (&http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(request)
|
|
||||||
if err != nil {
|
|
||||||
source.Message = "model list unavailable; enter the served model name"
|
|
||||||
} else {
|
|
||||||
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
|
|
||||||
response.Body.Close()
|
|
||||||
if response.StatusCode == http.StatusOK && readErr == nil {
|
|
||||||
var data struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"data"`
|
|
||||||
Models []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"models"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &data); err == nil {
|
|
||||||
source.Models = []string{}
|
|
||||||
for _, model := range data.Data {
|
|
||||||
source.Models = append(source.Models, model.ID)
|
|
||||||
}
|
|
||||||
for _, model := range data.Models {
|
|
||||||
source.Models = append(source.Models, model.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
source.Message = fmt.Sprintf("model list HTTP %d; provide model name and credentials if required", response.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = append(results, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
+77
-111
@@ -59,8 +59,7 @@ type IAppService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type appInstallHooks struct {
|
type appInstallHooks struct {
|
||||||
AfterCopyData func(appInstall *model.AppInstall) error
|
AfterCopyData func(appInstall *model.AppInstall) error
|
||||||
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAppService() IAppService {
|
func NewIAppService() IAppService {
|
||||||
@@ -135,7 +134,6 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
|
|||||||
ID: ap.ID,
|
ID: ap.ID,
|
||||||
Name: ap.Name,
|
Name: ap.Name,
|
||||||
Key: ap.Key,
|
Key: ap.Key,
|
||||||
Icon: appicon.RemoteURL(ap.Icon),
|
|
||||||
Limit: ap.Limit,
|
Limit: ap.Limit,
|
||||||
GpuSupport: ap.GpuSupport,
|
GpuSupport: ap.GpuSupport,
|
||||||
Recommend: ap.Recommend,
|
Recommend: ap.Recommend,
|
||||||
@@ -260,45 +258,26 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
|
|
||||||
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
|
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
|
||||||
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
if !fileOp.Stat(versionPath) || detail.Update {
|
||||||
return appDetailDTO, err
|
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
||||||
}
|
|
||||||
packageReady := err == nil
|
|
||||||
if packageReady && app.Resource == constant.AppResourceRemote {
|
|
||||||
detail.Update = false
|
|
||||||
appDetailDTO.Update = false
|
|
||||||
}
|
|
||||||
if packageReady && strings.TrimSpace(detail.DockerCompose) == "" {
|
|
||||||
compose, err := fileOp.GetContent(filepath.Join(versionPath, "docker-compose.yml"))
|
|
||||||
if err != nil {
|
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
if err := cacheAppDetailCompose(&detail, compose); err != nil {
|
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch app.Type {
|
switch app.Type {
|
||||||
case constant.RuntimePHP:
|
case constant.RuntimePHP:
|
||||||
paramMap := make(map[string]interface{})
|
paramsPath := filepath.Join(versionPath, "data.yml")
|
||||||
if app.Resource == constant.AppResourceRemote {
|
if !fileOp.Stat(paramsPath) {
|
||||||
if err = json.Unmarshal([]byte(detail.Params), ¶mMap); err != nil {
|
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
appDetailDTO.Params = paramMap
|
|
||||||
} else {
|
|
||||||
paramsPath := filepath.Join(versionPath, "data.yml")
|
|
||||||
if !fileOp.Stat(paramsPath) {
|
|
||||||
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
|
|
||||||
}
|
|
||||||
param, err := fileOp.GetContent(paramsPath)
|
|
||||||
if err != nil {
|
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
if err = yaml.Unmarshal(param, ¶mMap); err != nil {
|
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
appDetailDTO.Params = paramMap["additionalProperties"]
|
|
||||||
}
|
}
|
||||||
|
param, err := fileOp.GetContent(paramsPath)
|
||||||
|
if err != nil {
|
||||||
|
return appDetailDTO, err
|
||||||
|
}
|
||||||
|
paramMap := make(map[string]interface{})
|
||||||
|
if err = yaml.Unmarshal(param, ¶mMap); err != nil {
|
||||||
|
return appDetailDTO, err
|
||||||
|
}
|
||||||
|
appDetailDTO.Params = paramMap["additionalProperties"]
|
||||||
composePath := filepath.Join(versionPath, "docker-compose.yml")
|
composePath := filepath.Join(versionPath, "docker-compose.yml")
|
||||||
if !fileOp.Stat(composePath) {
|
if !fileOp.Stat(composePath) {
|
||||||
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
|
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
|
||||||
@@ -307,17 +286,15 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
if err := validateAppDetailCompose(compose); err != nil {
|
composeMap := make(map[string]interface{})
|
||||||
return appDetailDTO, err
|
|
||||||
}
|
|
||||||
var composeMap struct {
|
|
||||||
Services map[string]interface{} `yaml:"services"`
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
|
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
|
||||||
return appDetailDTO, err
|
return appDetailDTO, err
|
||||||
}
|
}
|
||||||
for k := range composeMap.Services {
|
if service, ok := composeMap["services"]; ok {
|
||||||
appDetailDTO.Image = k
|
servicesMap := service.(map[string]interface{})
|
||||||
|
for k := range servicesMap {
|
||||||
|
appDetailDTO.Image = k
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -328,10 +305,23 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
|||||||
appDetailDTO.Params = paramMap
|
appDetailDTO.Params = paramMap
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
if appDetailDTO.DockerCompose == "" {
|
||||||
return appDetailDTO, err
|
filename := filepath.Base(appDetailDTO.DownloadUrl)
|
||||||
|
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
|
||||||
|
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||||
|
if statusCode == http.StatusNotFound {
|
||||||
|
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
||||||
|
}
|
||||||
|
if statusCode > 200 {
|
||||||
|
return appDetailDTO, buserr.WithDetail("ErrGetCompose", string(composeRes), err)
|
||||||
|
}
|
||||||
|
detail.DockerCompose = string(composeRes)
|
||||||
|
_ = appDetailRepo.Update(context.Background(), detail)
|
||||||
|
appDetailDTO.DockerCompose = string(composeRes)
|
||||||
}
|
}
|
||||||
appDetailDTO.DockerCompose = detail.DockerCompose
|
|
||||||
|
|
||||||
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
|
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
|
||||||
|
|
||||||
@@ -349,13 +339,6 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ensureAppDetailCompose(app, &appDetail); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res.AppDetail = appDetail
|
res.AppDetail = appDetail
|
||||||
paramMap := make(map[string]interface{})
|
paramMap := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(appDetail.Params), ¶mMap); err != nil {
|
if err := json.Unmarshal([]byte(appDetail.Params), ¶mMap); err != nil {
|
||||||
@@ -443,13 +426,25 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
App: app,
|
App: app,
|
||||||
}
|
}
|
||||||
composeMap := make(map[string]interface{})
|
composeMap := make(map[string]interface{})
|
||||||
|
var composeRes []byte
|
||||||
if req.EditCompose {
|
if req.EditCompose {
|
||||||
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err = ensureAppDetailCompose(app, &appDetail); err != nil {
|
if appDetail.DockerCompose == "" {
|
||||||
return
|
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
|
||||||
|
var statusCode int
|
||||||
|
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||||
|
if statusCode == http.StatusNotFound {
|
||||||
|
err = buserr.New("ErrAppVersionUnavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appDetail.DockerCompose = string(composeRes)
|
||||||
|
_ = appDetailRepo.Update(context.Background(), appDetail)
|
||||||
}
|
}
|
||||||
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
|
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
|
||||||
return
|
return
|
||||||
@@ -483,22 +478,6 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
req.Params[constant.ContainerName] = containerName
|
req.Params[constant.ContainerName] = containerName
|
||||||
appInstall.ContainerName = containerName
|
appInstall.ContainerName = containerName
|
||||||
|
|
||||||
modelAccountBaseURL := ""
|
|
||||||
if app.Key == "ollama" {
|
|
||||||
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
|
|
||||||
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
|
|
||||||
if appInstall.ModelAccountBaseURLType == "" {
|
|
||||||
appInstall.ModelAccountBaseURLType = "container"
|
|
||||||
}
|
|
||||||
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
|
|
||||||
}
|
|
||||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
|
||||||
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
index := 0
|
index := 0
|
||||||
serviceName := ""
|
serviceName := ""
|
||||||
for k := range servicesMap {
|
for k := range servicesMap {
|
||||||
@@ -525,26 +504,6 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
composeByte []byte
|
composeByte []byte
|
||||||
paramByte []byte
|
paramByte []byte
|
||||||
)
|
)
|
||||||
if (app.Key == "ollama" || app.Key == "vllm") && req.GPU != nil {
|
|
||||||
gpuServiceName := appInstall.ServiceName
|
|
||||||
imageType := "nvidia"
|
|
||||||
if app.Key == "vllm" {
|
|
||||||
imageType = req.GPUImageType
|
|
||||||
if len(servicesMap) > 1 {
|
|
||||||
gpuServiceName = "vllm"
|
|
||||||
if _, ok := servicesMap[gpuServiceName]; !ok {
|
|
||||||
gpuServiceName = "vllm-dspark"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
service, ok := servicesMap[gpuServiceName].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, buserr.New("ErrFileParse")
|
|
||||||
}
|
|
||||||
if err = ApplyModelGPU(service, req.GPU, req.Params, imageType); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeByte, err = yaml.Marshal(composeMap)
|
composeByte, err = yaml.Marshal(composeMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -618,7 +577,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if app.Key == "openresty" {
|
if app.Key == "openresty" {
|
||||||
if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
|
if err = handleSiteDir(app, appDetail, req, t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = handleOpenrestyFile(appInstall); err != nil {
|
if err = handleOpenrestyFile(appInstall); err != nil {
|
||||||
@@ -629,10 +588,6 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
updateToolApp(appInstall)
|
updateToolApp(appInstall)
|
||||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
|
||||||
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,10 +880,22 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
|
|||||||
res.CanUpdate = true
|
res.CanUpdate = true
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
list, err := readAppList()
|
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
||||||
if err != nil || list.LastModified != lastModified {
|
for _, app := range apps {
|
||||||
list, err = getAppList()
|
if app.Icon == "" {
|
||||||
|
res.CanUpdate = true
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if appicon.IsIconFile(app.Icon) {
|
||||||
|
fileName, _ := appicon.ParseIconField(app.Icon)
|
||||||
|
if fileName == "" || !appicon.IconFileExists(fileName) {
|
||||||
|
res.CanUpdate = true
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
list, err := getAppList()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@@ -959,26 +926,29 @@ func getAppFromRepo(downloadPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getAppList() (*dto.AppList, error) {
|
func getAppList() (*dto.AppList, error) {
|
||||||
|
list := &dto.AppList{}
|
||||||
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
|
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return readAppList()
|
|
||||||
}
|
|
||||||
|
|
||||||
func readAppList() (*dto.AppList, error) {
|
|
||||||
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
||||||
data, err := os.ReadFile(listFile)
|
file, err := os.Open(listFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list := &dto.AppList{}
|
defer file.Close()
|
||||||
if err = json.Unmarshal(data, list); err != nil {
|
if err = json.NewDecoder(file).Decode(list); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var InitTypes = map[string]struct{}{
|
||||||
|
"runtime": {},
|
||||||
|
"php": {},
|
||||||
|
"node": {},
|
||||||
|
}
|
||||||
|
|
||||||
func deleteCustomApp() {
|
func deleteCustomApp() {
|
||||||
installs, err := appInstallRepo.ListBy(context.Background())
|
installs, err := appInstallRepo.ListBy(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1076,10 +1046,6 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
|
|||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if appicon.IsRemoteURL(app.Icon) {
|
|
||||||
return nil, app.Icon, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if appicon.IsIconFile(app.Icon) {
|
if appicon.IsIconFile(app.Icon) {
|
||||||
fileName, etag := appicon.ParseIconField(app.Icon)
|
fileName, etag := appicon.ParseIconField(app.Icon)
|
||||||
iconBytes, err := appicon.ReadIconFile(fileName)
|
iconBytes, err := appicon.ReadIconFile(fileName)
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func appCacheTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cache.db")), &gorm.Config{
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}, &model.Setting{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
|
|
||||||
global.DB = db
|
|
||||||
global.Dir = global.SystemDir{AppResourceDir: t.TempDir()}
|
|
||||||
global.Dir.RemoteAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceRemote)
|
|
||||||
global.Dir.LocalAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceLocal)
|
|
||||||
global.Dir.CustomAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceCustom)
|
|
||||||
global.LOG = logrus.New()
|
|
||||||
global.LOG.SetOutput(io.Discard)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err == nil {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err := db.Create(&model.Setting{Key: "AppStoreLastModified", Value: "42"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func appCacheTestArchive(t *testing.T, name, content string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
gz := gzip.NewWriter(&buf)
|
|
||||||
tw := tar.NewWriter(gz)
|
|
||||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(tw, content); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := gz.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
func appCacheTestWrite(t *testing.T, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppDownloadURLPreservesSourceQuery(t *testing.T) {
|
|
||||||
for _, rawURL := range []string{
|
|
||||||
"https://apps.1panel.pro/php/8/php-8.tar.gz",
|
|
||||||
"https://apps.1panel.pro/php/8/php-8.tar.gz?token=a%2Bb®ion=intl&v=old#archive",
|
|
||||||
} {
|
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
|
||||||
got, err := appDownloadURL(rawURL, "42 /next")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(got)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
original, _ := url.Parse(rawURL)
|
|
||||||
wantQuery := original.Query()
|
|
||||||
wantQuery.Set("v", "42 /next")
|
|
||||||
if !reflect.DeepEqual(parsed.Query(), wantQuery) || parsed.Path != original.Path || parsed.Fragment != original.Fragment {
|
|
||||||
t.Fatalf("download URL lost source fields: %s", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if _, err := appDownloadURL("https://apps.1panel.pro/%zz", "42"); err == nil {
|
|
||||||
t.Fatal("invalid source URL was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDownloadAppRefreshesAllOfficialCachedPackages(t *testing.T) {
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
app := model.App{Key: "mysql", Name: "MySQL", Type: "website", Resource: constant.AppResourceRemote}
|
|
||||||
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
|
||||||
dataPath := filepath.Join(versionDir, "data.yml")
|
|
||||||
stampPath := filepath.Join(versionDir, ".1panel-appstore-version")
|
|
||||||
appCacheTestWrite(t, dataPath, "stale")
|
|
||||||
archives := map[string][]byte{
|
|
||||||
"42": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-42"),
|
|
||||||
"43": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-43"),
|
|
||||||
}
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if r.URL.Query().Get("token") != "a+b" || r.URL.Query().Get("region") != "intl" {
|
|
||||||
t.Errorf("source query was lost: %s", r.URL.RawQuery)
|
|
||||||
}
|
|
||||||
version := r.URL.Query().Get("v")
|
|
||||||
if version != "42" && version != "43" {
|
|
||||||
t.Errorf("unexpected app store version: %q", version)
|
|
||||||
}
|
|
||||||
_, _ = w.Write(archives[version])
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz?token=a%2Bb®ion=intl", Update: false}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, step := range []struct {
|
|
||||||
version string
|
|
||||||
calls int32
|
|
||||||
update bool
|
|
||||||
}{
|
|
||||||
{"42", 1, false}, // An existing directory without a stamp must be refreshed.
|
|
||||||
{"42", 1, false},
|
|
||||||
{"43", 2, false},
|
|
||||||
{"43", 3, true},
|
|
||||||
} {
|
|
||||||
if err := db.Model(&model.Setting{}).Where("key = ?", "AppStoreLastModified").Update("value", step.version).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
detail.Update = step.update
|
|
||||||
if err := db.Model(&detail).UpdateColumn("update", detail.Update).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := downloadApp(app, detail, nil, nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if calls.Load() != step.calls {
|
|
||||||
t.Fatalf("version %s: HTTP calls = %d, want %d", step.version, calls.Load(), step.calls)
|
|
||||||
}
|
|
||||||
for name, want := range map[string]string{dataPath: "fresh-" + step.version, stampPath: step.version} {
|
|
||||||
data, err := os.ReadFile(name)
|
|
||||||
if err != nil || string(data) != want {
|
|
||||||
t.Fatalf("%s = %q, %v; want %q", name, data, err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
|
||||||
t.Fatalf("refresh altered source URL or left update flag: %+v", stored)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDownloadAppFailureKeepsCacheVersionAndUpdateFlag(t *testing.T) {
|
|
||||||
for _, failure := range []string{"download", "decompress"} {
|
|
||||||
t.Run(failure, func(t *testing.T) {
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
app := model.App{Key: "mysql", Resource: constant.AppResourceRemote}
|
|
||||||
stampPath := filepath.Join(app.GetAppResourcePath(), "8", ".1panel-appstore-version")
|
|
||||||
appCacheTestWrite(t, stampPath, "41")
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if failure == "download" {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, "not an archive")
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz", Update: true}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
install := model.AppInstall{}
|
|
||||||
if err := downloadApp(app, detail, &install, nil); err == nil {
|
|
||||||
t.Fatal("failed refresh was accepted")
|
|
||||||
}
|
|
||||||
if install.Status != constant.StatusDownloadErr || install.Message == "" {
|
|
||||||
t.Fatalf("installation did not report refresh failure: %+v", install)
|
|
||||||
}
|
|
||||||
stamp, err := os.ReadFile(stampPath)
|
|
||||||
if err != nil || string(stamp) != "41" {
|
|
||||||
t.Fatalf("failed refresh updated stamp: %q, %v", stamp, err)
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
|
||||||
t.Fatalf("failed refresh changed database state: %+v", stored)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPHPAppDetailUsesSynchronizedOfficialParams(t *testing.T) {
|
|
||||||
const oldYAML = "additionalProperties:\n formFields:\n - envKey: PHP_VERSION\n type: select\n default: 8.1.34\n"
|
|
||||||
const newJSON = `{"formFields":[{"envKey":"PHP_VERSION","type":"select","default":"8.1.35"}]}`
|
|
||||||
const compose = "services:\n php:\n image: php:test\n"
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, resource, compose string
|
|
||||||
update bool
|
|
||||||
}{
|
|
||||||
{"remote", constant.AppResourceRemote, compose, false},
|
|
||||||
{"local", constant.AppResourceLocal, compose, false},
|
|
||||||
{"custom", constant.AppResourceCustom, compose, false},
|
|
||||||
{"remote-fetches-compose", constant.AppResourceRemote, "", true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
app := model.App{Key: "php", Name: "PHP", Type: constant.RuntimePHP, Resource: tc.resource}
|
|
||||||
if err := db.Omit(clause.Associations).Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
|
||||||
appCacheTestWrite(t, filepath.Join(versionDir, "data.yml"), oldYAML)
|
|
||||||
appCacheTestWrite(t, filepath.Join(versionDir, "docker-compose.yml"), compose)
|
|
||||||
archive := appCacheTestArchive(t, "php/8/data.yml", oldYAML)
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if r.URL.Query().Get("v") != "42" {
|
|
||||||
t.Errorf("missing app store version: %s", r.URL.RawQuery)
|
|
||||||
}
|
|
||||||
if r.URL.Path == "/docker-compose.yml" {
|
|
||||||
_, _ = io.WriteString(w, compose)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(archive)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
detail := model.AppDetail{AppId: app.ID, Version: "8", Params: newJSON, DockerCompose: tc.compose, Update: tc.update, DownloadUrl: server.URL + "/php-8.tar.gz"}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := (AppService{}).GetAppDetail(app.ID, "8", "runtime")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
wantVersion, wantCalls := "8.1.34", int32(0)
|
|
||||||
if tc.resource == constant.AppResourceRemote {
|
|
||||||
wantVersion, wantCalls = "8.1.35", 1
|
|
||||||
}
|
|
||||||
want := map[string]interface{}{"formFields": []interface{}{map[string]interface{}{
|
|
||||||
"envKey": "PHP_VERSION", "type": "select", "default": wantVersion,
|
|
||||||
}}}
|
|
||||||
if !reflect.DeepEqual(got.Params, want) || got.Image != "php" || calls.Load() != wantCalls {
|
|
||||||
encoded, _ := json.Marshal(got.Params)
|
|
||||||
t.Fatalf("params=%s, image=%s, HTTP calls=%d; want version %s, calls %d", encoded, got.Image, calls.Load(), wantVersion, wantCalls)
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.Update || got.Update {
|
|
||||||
t.Fatal("fetching the compose file restored a stale update flag")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessOneAppDefersComposeUntilRequested(t *testing.T) {
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
const compose = "services:\n php:\n image: php:test\n"
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if r.URL.Path != "/php/8/docker-compose.yml" || r.URL.Query().Get("v") != "42" {
|
|
||||||
t.Errorf("unversioned initial compose request: %s", r.URL.String())
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, compose)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: constant.AppResourceRemote}
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ctx := appSyncContext{
|
|
||||||
baseRemoteUrl: server.URL,
|
|
||||||
systemVersion: "2.4.0",
|
|
||||||
list: &dto.AppList{LastModified: 42},
|
|
||||||
}
|
|
||||||
result := ctx.processOneApp(appWorkItem{
|
|
||||||
app: app,
|
|
||||||
appDef: dto.AppDefine{
|
|
||||||
AppProperty: dto.AppProperty{Key: "php", Type: constant.RuntimePHP},
|
|
||||||
Versions: []dto.AppConfigVersion{{Name: "8", AppForm: dto.AppForm{
|
|
||||||
FormFields: []dto.AppFormFields{{EnvKey: "PHP_VERSION", Default: "8.1.35", Type: "select"}},
|
|
||||||
}}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if calls.Load() != 0 || len(result.Details) != 1 {
|
|
||||||
t.Fatalf("initial compose refresh failed: calls=%d, result=%+v", calls.Load(), result)
|
|
||||||
}
|
|
||||||
detail := result.Details[0]
|
|
||||||
if detail.DockerCompose != "" || detail.DownloadUrl != server.URL+"/php/8/php-8.tar.gz" {
|
|
||||||
t.Fatalf("sync fetched compose or changed the canonical archive URL: %+v", detail)
|
|
||||||
}
|
|
||||||
detail.AppId = app.ID
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for range 2 {
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if calls.Load() != 1 || detail.DockerCompose != compose {
|
|
||||||
t.Fatalf("on-demand compose did not download once and cache: calls=%d, detail=%+v", calls.Load(), detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureAppDetailCompose(app model.App, detail *model.AppDetail) error {
|
|
||||||
if strings.TrimSpace(detail.DockerCompose) != "" && validateAppDetailCompose([]byte(detail.DockerCompose)) == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var content []byte
|
|
||||||
var err error
|
|
||||||
if app.IsLocalApp() || app.IsCustomApp() {
|
|
||||||
content, err = os.ReadFile(filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"))
|
|
||||||
} else {
|
|
||||||
var composeURL string
|
|
||||||
if detail.DownloadUrl == "" {
|
|
||||||
composeURL = fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, detail.Version)
|
|
||||||
} else {
|
|
||||||
var source *url.URL
|
|
||||||
source, err = url.Parse(detail.DownloadUrl)
|
|
||||||
if err == nil {
|
|
||||||
source.Path = path.Join(path.Dir(source.Path), "docker-compose.yml")
|
|
||||||
source.RawPath = ""
|
|
||||||
composeURL = source.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
composeURL, err = currentAppDownloadURL(composeURL)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
var status int
|
|
||||||
status, content, err = req_helper.HandleRequest(composeURL, http.MethodGet, constant.TimeOut20s)
|
|
||||||
if status == http.StatusNotFound {
|
|
||||||
return buserr.New("ErrAppVersionUnavailable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
|
||||||
}
|
|
||||||
return cacheAppDetailCompose(detail, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAppDetailCompose(content []byte) error {
|
|
||||||
var compose struct {
|
|
||||||
Services map[string]map[string]interface{} `yaml:"services"`
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(content, &compose); err != nil {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
|
||||||
}
|
|
||||||
if len(compose.Services) == 0 {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has no services", nil)
|
|
||||||
}
|
|
||||||
for name, service := range compose.Services {
|
|
||||||
if strings.TrimSpace(name) == "" || len(service) == 0 {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has an empty service", nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheAppDetailCompose(detail *model.AppDetail, content []byte) error {
|
|
||||||
if err := validateAppDetailCompose(content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stored, err := appDetailRepo.CacheCompose(*detail, string(content))
|
|
||||||
if err != nil {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
|
|
||||||
}
|
|
||||||
if !stored {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
|
|
||||||
}
|
|
||||||
detail.DockerCompose = string(content)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestComposeSyncOnlyInvalidatesChangedVersionResources(t *testing.T) {
|
|
||||||
form := dto.AppForm{FormFields: []dto.AppFormFields{{EnvKey: "PORT", Default: 8080}}}
|
|
||||||
params, err := json.Marshal(form)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
_, _ = io.WriteString(w, "services:\n demo:\n image: demo:unexpected\n")
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
for _, appType := range []string{"runtime", "php", "node", "java", "go", "python", "dotnet", "website"} {
|
|
||||||
t.Run(appType, func(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*model.AppDetail, *dto.AppConfigVersion)
|
|
||||||
keepCache bool
|
|
||||||
}{
|
|
||||||
{"unchanged", func(_ *model.AppDetail, _ *dto.AppConfigVersion) {}, true},
|
|
||||||
{"package update flag does not expire compose", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Update = true }, true},
|
|
||||||
{"version revision changed", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified++ }, false},
|
|
||||||
{"unknown revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = 0 }, false},
|
|
||||||
{"negative revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = -1 }, false},
|
|
||||||
{"form changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Params = `{"formFields":[]}` }, false},
|
|
||||||
{"source changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.DownloadUrl += ".old" }, false},
|
|
||||||
{"new version", func(d *model.AppDetail, _ *dto.AppConfigVersion) { *d = model.AppDetail{Version: "1.0"} }, false},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
const cached = "services:\n demo:\n image: demo:cached\n"
|
|
||||||
old := model.AppDetail{Version: "1.0", LastModified: 42, Params: string(params), DownloadUrl: server.URL + "/demo/1.0/demo-1.0.tar.gz", DockerCompose: cached}
|
|
||||||
version := dto.AppConfigVersion{Name: "1.0", LastModified: 42, AppForm: form}
|
|
||||||
tc.mutate(&old, &version)
|
|
||||||
ctx := appSyncContext{baseRemoteUrl: server.URL, systemVersion: "2.4.0"}
|
|
||||||
app := model.App{Key: "demo", Type: appType, Resource: constant.AppResourceRemote, Details: []model.AppDetail{old}}
|
|
||||||
got := ctx.processOneApp(appWorkItem{app: app, appDef: dto.AppDefine{AppProperty: dto.AppProperty{Key: "demo"}, Versions: []dto.AppConfigVersion{version}}})
|
|
||||||
if len(got.Details) != 1 || calls.Load() != 0 {
|
|
||||||
t.Fatalf("sync fetched compose or lost version: %+v, calls=%d", got, calls.Load())
|
|
||||||
}
|
|
||||||
detail := got.Details[0]
|
|
||||||
want := ""
|
|
||||||
if tc.keepCache {
|
|
||||||
want = cached
|
|
||||||
}
|
|
||||||
if detail.DockerCompose != want || detail.LastModified != version.LastModified || detail.Params != string(params) || !detail.Update {
|
|
||||||
t.Fatalf("cache decision or version metadata changed: %+v", detail)
|
|
||||||
}
|
|
||||||
if app.Details[0].DockerCompose != old.DockerCompose {
|
|
||||||
t.Fatal("sync mutated its input template")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const composeTestTemplate = "services:\n demo:\n image: demo:latest\n network_mode: host\n"
|
|
||||||
|
|
||||||
func composeTestDetail(t *testing.T, resource, source string) (*gorm.DB, model.App, model.AppDetail) {
|
|
||||||
t.Helper()
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
app := model.App{Key: "demo", Name: "Demo", Resource: resource, Type: "website"}
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
detail := model.AppDetail{AppId: app.ID, Version: "1.0", LastModified: 42, Params: `{"formFields":[]}`, DownloadUrl: source}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return db, app, detail
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeOnDemandLoadsOnceAndPreservesSource(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if r.URL.Path != "/demo/1.0/docker-compose.yml" || r.URL.Query().Get("signature") != "a+b" || r.URL.Query().Get("v") != "42" {
|
|
||||||
t.Errorf("unexpected Compose source: %s", r.URL)
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, composeTestTemplate)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo/1.0/demo-1.0.tar.gz?signature=a%2Bb")
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if calls.Load() != 1 || detail.DockerCompose != composeTestTemplate {
|
|
||||||
t.Fatalf("cache miss after loading: calls=%d, detail=%+v", calls.Load(), detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.DockerCompose != composeTestTemplate || stored.LastModified != 42 || stored.DownloadUrl != detail.DownloadUrl || stored.Params != detail.Params || !stored.UpdatedAt.Equal(detail.UpdatedAt) {
|
|
||||||
t.Fatalf("cache write altered version metadata: %+v", stored)
|
|
||||||
}
|
|
||||||
res, err := (AppService{}).GetAppDetailByID(detail.ID)
|
|
||||||
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
|
|
||||||
t.Fatalf("ID details did not reuse template/HostMode: %+v, %v, calls=%d", res, err, calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeOnDemandIDDetailsFillMissingTemplate(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
_, _ = io.WriteString(w, composeTestTemplate)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
_, _, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
|
||||||
res, err := (AppService{}).GetAppDetailByID(detail.ID)
|
|
||||||
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
|
|
||||||
t.Fatalf("missing template gave wrong ID details: %+v, %v, calls=%d", res, err, calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeOnDemandLocalAndCustomUseTheirDirectories(t *testing.T) {
|
|
||||||
for _, resource := range []string{constant.AppResourceLocal, constant.AppResourceCustom} {
|
|
||||||
t.Run(resource, func(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
_, app, detail := composeTestDetail(t, resource, server.URL+"/must-not-fetch.tar.gz")
|
|
||||||
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), composeTestTemplate)
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if detail.DockerCompose != composeTestTemplate || calls.Load() != 0 {
|
|
||||||
t.Fatalf("local resource used remote template: %+v, calls=%d", detail, calls.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeOnDemandRejectsDownloadAndTemplateFailures(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, body string
|
|
||||||
status int
|
|
||||||
}{
|
|
||||||
{"not found", "missing", http.StatusNotFound},
|
|
||||||
{"server error", "failure", http.StatusServiceUnavailable},
|
|
||||||
{"empty", "", http.StatusOK},
|
|
||||||
{"HTML", "<html>blocked</html>", http.StatusOK},
|
|
||||||
{"malformed YAML", "services: [", http.StatusOK},
|
|
||||||
{"missing services", "version: '3'", http.StatusOK},
|
|
||||||
{"empty services", "services: {}", http.StatusOK},
|
|
||||||
{"empty service", "services:\n demo:", http.StatusOK},
|
|
||||||
{"string service", "services:\n demo: blocked", http.StatusOK},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(tc.status)
|
|
||||||
_, _ = io.WriteString(w, tc.body)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err == nil {
|
|
||||||
t.Fatal("invalid template was accepted")
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if detail.DockerCompose != "" || stored.DockerCompose != "" {
|
|
||||||
t.Fatal("failed response was cached")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeCacheRejectsConcurrentMetadataUpdates(t *testing.T) {
|
|
||||||
for _, field := range []string{"last_modified", "params", "download_url"} {
|
|
||||||
t.Run(field, func(t *testing.T) {
|
|
||||||
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
|
||||||
value := interface{}("new value")
|
|
||||||
if field == "last_modified" {
|
|
||||||
value = 43
|
|
||||||
}
|
|
||||||
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "docker_compose": "new template"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil {
|
|
||||||
t.Fatal("stale template overwrote newer metadata")
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.DockerCompose != "new template" || detail.DockerCompose != "" {
|
|
||||||
t.Fatalf("stale cache result was retained: %+v", stored)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeCachePropagatesWriteFailure(t *testing.T) {
|
|
||||||
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
|
||||||
if err := db.Exec("CREATE TRIGGER reject_compose BEFORE UPDATE OF docker_compose ON app_details BEGIN SELECT RAISE(FAIL, 'test write failure'); END").Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil || detail.DockerCompose != "" {
|
|
||||||
t.Fatal("failed cache write was ignored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarkDownloadedPreservesComposeAndRejectsChangedMetadata(t *testing.T) {
|
|
||||||
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
|
|
||||||
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{"update": true, "docker_compose": composeTestTemplate}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
stored, err := appDetailRepo.MarkDownloaded(detail)
|
|
||||||
if err != nil || !stored {
|
|
||||||
t.Fatalf("matching download marker failed: stored=%v err=%v", stored, err)
|
|
||||||
}
|
|
||||||
var fresh model.AppDetail
|
|
||||||
if err := db.First(&fresh, detail.ID).Error; err != nil || fresh.Update || fresh.DockerCompose != composeTestTemplate {
|
|
||||||
t.Fatalf("download marker overwrote Compose: %+v, %v", fresh, err)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"last_modified", "params", "download_url"} {
|
|
||||||
t.Run(field, func(t *testing.T) {
|
|
||||||
var snapshot model.AppDetail
|
|
||||||
if err := db.First(&snapshot, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
value := interface{}("new metadata")
|
|
||||||
if field == "last_modified" {
|
|
||||||
value = snapshot.LastModified + 1
|
|
||||||
}
|
|
||||||
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "update": true}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
stored, err := appDetailRepo.MarkDownloaded(snapshot)
|
|
||||||
if err != nil || stored {
|
|
||||||
t.Fatalf("stale download marked new resources ready: stored=%v err=%v", stored, err)
|
|
||||||
}
|
|
||||||
if err := db.First(&fresh, detail.ID).Error; err != nil || !fresh.Update || fresh.DockerCompose != composeTestTemplate {
|
|
||||||
t.Fatalf("stale marker overwrote template or update flag: %+v, %v", fresh, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRuntimeDetailUsesValidPackageComposeWithoutAnotherGET(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
archive := appCacheTestArchive(t, "demo/1.0/docker-compose.yml", composeTestTemplate)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if !strings.HasSuffix(r.URL.Path, ".tar.gz") {
|
|
||||||
t.Errorf("runtime requested Compose after obtaining a valid package: %s", r.URL)
|
|
||||||
}
|
|
||||||
_, _ = w.Write(archive)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
_, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
|
||||||
app.Type = constant.RuntimeNode
|
|
||||||
if err := global.DB.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
|
|
||||||
if err != nil || res.DockerCompose != composeTestTemplate || !res.HostMode || calls.Load() != 1 {
|
|
||||||
t.Fatalf("package Compose was not reused: %+v, %v, calls=%d", res, err, calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRuntimeDetailDoesNotCacheStaleTemplateAfterPackageFailure(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if strings.HasSuffix(r.URL.Path, ".tar.gz") {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, composeTestTemplate)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
|
|
||||||
app.Type = constant.RuntimeNode
|
|
||||||
if err := db.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), "services:\n stale:\n image: stale:old\n")
|
|
||||||
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
|
|
||||||
if err != nil || res.DockerCompose != composeTestTemplate || calls.Load() != 2 {
|
|
||||||
t.Fatalf("failed package download reused a stale local template: %+v, %v, calls=%d", res, err, calls.Load())
|
|
||||||
}
|
|
||||||
var stored model.AppDetail
|
|
||||||
if err := db.First(&stored, detail.ID).Error; err != nil || stored.DockerCompose != composeTestTemplate {
|
|
||||||
t.Fatalf("stale package contents were cached: %+v, %v", stored, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
const composeUpgradeCurrent = `services:
|
|
||||||
demo:
|
|
||||||
image: demo:1.0
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: ${CPUS}
|
|
||||||
memory: ${MEMORY_LIMIT}
|
|
||||||
`
|
|
||||||
|
|
||||||
func composeUpgradeDB(t *testing.T, resourceURL string) (*gorm.DB, model.App, model.AppInstall, []model.AppDetail) {
|
|
||||||
t.Helper()
|
|
||||||
db := iconSyncDB(t)
|
|
||||||
if err := db.AutoMigrate(&model.AppIgnoreUpgrade{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
app := model.App{Key: "demo", Name: "Demo", Type: "runtime", Resource: constant.AppResourceRemote, CrossVersionUpdate: true}
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var details []model.AppDetail
|
|
||||||
for _, version := range []string{"1.1", "1.0", "1.3", "1.2"} {
|
|
||||||
detail := model.AppDetail{
|
|
||||||
AppId: app.ID, Version: version, LastModified: 42, Params: `{"formFields":[]}`, Status: constant.AppNormal,
|
|
||||||
DownloadUrl: resourceURL + "/demo/" + version + "/demo-" + version + ".tar.gz",
|
|
||||||
}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
details = append(details, detail)
|
|
||||||
}
|
|
||||||
if err := db.Create(&model.AppIgnoreUpgrade{AppID: app.ID, AppDetailID: details[2].ID, Scope: "version"}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
install := model.AppInstall{
|
|
||||||
AppId: app.ID, AppDetailId: details[1].ID, Name: "demo-install", Version: "1.0", App: app,
|
|
||||||
ServiceName: "demo", ContainerName: "demo-container", Status: constant.StatusRunning,
|
|
||||||
Env: `{"CONTAINER_NAME":"demo-container"}`, DockerCompose: composeUpgradeCurrent,
|
|
||||||
}
|
|
||||||
if err := db.Omit(clause.Associations).Create(&install).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return db, app, install, details
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpgradeComposeLoadsOnlyEffectiveTarget(t *testing.T) {
|
|
||||||
var mu sync.Mutex
|
|
||||||
requests := make(map[string]int)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
mu.Lock()
|
|
||||||
requests[r.URL.Path]++
|
|
||||||
mu.Unlock()
|
|
||||||
if r.URL.Query().Get("v") != "42" {
|
|
||||||
t.Errorf("missing synchronized version: %s", r.URL)
|
|
||||||
}
|
|
||||||
version := strings.Split(r.URL.Path, "/")[2]
|
|
||||||
_, _ = fmt.Fprintf(w, "services:\n source:\n image: demo:%s\n", version)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, _, install, details := composeUpgradeDB(t, server.URL)
|
|
||||||
// An unselected bad cached template must not prevent listing or default preview.
|
|
||||||
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "invalid: [").Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service := NewIAppInstalledService()
|
|
||||||
noCompose := false
|
|
||||||
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, LoadCompose: &noCompose})
|
|
||||||
if err != nil || len(versions) != 2 || versions[0].Version != "1.2" || versions[1].Version != "1.1" {
|
|
||||||
t.Fatalf("pure versions did not preserve filtering/sorting: %+v, %v", versions, err)
|
|
||||||
}
|
|
||||||
for _, version := range versions {
|
|
||||||
if version.DockerCompose != "" {
|
|
||||||
t.Fatal("pure version request returned a rendered template")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(requests) != 0 {
|
|
||||||
t.Fatalf("pure version request fetched templates: %v", requests)
|
|
||||||
}
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
|
|
||||||
if err != nil || len(versions) != 2 || !strings.Contains(versions[0].DockerCompose, "demo:1.2") || versions[1].DockerCompose != "" {
|
|
||||||
t.Fatalf("default target preview = %+v, %v", versions, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if requests["/demo/1.2/docker-compose.yml"] != 1 || len(requests) != 1 {
|
|
||||||
t.Fatalf("default preview did not reuse only its own cache: %v", requests)
|
|
||||||
}
|
|
||||||
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "").Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.1"})
|
|
||||||
if err != nil || !strings.Contains(versions[1].DockerCompose, "demo:1.1") || versions[0].DockerCompose != "" {
|
|
||||||
t.Fatalf("selected target preview = %+v, %v", versions, err)
|
|
||||||
}
|
|
||||||
if requests["/demo/1.1/docker-compose.yml"] != 1 || len(requests) != 2 {
|
|
||||||
t.Fatalf("selection prefetched other candidates: %v", requests)
|
|
||||||
}
|
|
||||||
if _, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.3"}); err == nil {
|
|
||||||
t.Fatal("ignored target was accepted")
|
|
||||||
}
|
|
||||||
if len(requests) != 2 {
|
|
||||||
t.Fatal("invalid target triggered a template request")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpgradeComposeFetchFailurePreservesInstanceAndCache(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, _, install, details := composeUpgradeDB(t, server.URL)
|
|
||||||
if _, err := NewIAppInstalledService().GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID}); err == nil {
|
|
||||||
t.Fatal("failed target template load was accepted")
|
|
||||||
}
|
|
||||||
var stored model.AppInstall
|
|
||||||
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Status != install.Status || stored.Version != install.Version {
|
|
||||||
t.Fatalf("preview modified installed instance: %+v, %v", stored, err)
|
|
||||||
}
|
|
||||||
var detail model.AppDetail
|
|
||||||
if err := db.First(&detail, details[3].ID).Error; err != nil || detail.DockerCompose != "" {
|
|
||||||
t.Fatalf("failed response cached a template: %+v, %v", detail, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpgradePrepareComposeFailureDoesNotCutover(t *testing.T) {
|
|
||||||
for _, failure := range []string{"package download", "invalid package template"} {
|
|
||||||
t.Run(failure, func(t *testing.T) {
|
|
||||||
archive := appCacheTestArchive(t, "demo/1.2/docker-compose.yml", "services: []")
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if failure == "package download" {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(archive)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, _, install, details := composeUpgradeDB(t, server.URL)
|
|
||||||
upgrade := &appUpgradeContext{original: install, detail: details[3]}
|
|
||||||
if err := upgrade.prepare(&task.Task{Logger: global.LOG}); err == nil {
|
|
||||||
t.Fatal("invalid upgrade resources passed prepare")
|
|
||||||
}
|
|
||||||
if upgrade.stopAttempted || upgrade.downAttempted || upgrade.stageDir != "" {
|
|
||||||
t.Fatal("failed template preparation reached live cutover")
|
|
||||||
}
|
|
||||||
var stored model.AppInstall
|
|
||||||
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Env != install.Env || stored.Version != install.Version || stored.Status != install.Status {
|
|
||||||
t.Fatalf("failed prepare modified the instance: %+v, %v", stored, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExplicitConfigComposeDetectsCustomAndReusesCurrentCache(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
custom bool
|
|
||||||
failure bool
|
|
||||||
}{
|
|
||||||
{name: "original"},
|
|
||||||
{name: "custom", custom: true},
|
|
||||||
{name: "fetch failure", failure: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
calls := 0
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls++
|
|
||||||
if r.URL.Path != "/demo/1.0/docker-compose.yml" {
|
|
||||||
t.Errorf("configuration loaded a different version: %s", r.URL)
|
|
||||||
}
|
|
||||||
if tc.failure {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, "services:\n source:\n image: demo:1.0\n")
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, _, install, _ := composeUpgradeDB(t, server.URL)
|
|
||||||
if tc.custom {
|
|
||||||
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n environment:\n USER_SETTING: preserve", 1)
|
|
||||||
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isEditCompose(install) || calls != 0 {
|
|
||||||
t.Fatal("list-time custom check fetched a missing template")
|
|
||||||
}
|
|
||||||
result, err := NewIAppInstalledService().GetParams(install.ID)
|
|
||||||
if tc.failure {
|
|
||||||
if err == nil || result != nil {
|
|
||||||
t.Fatal("failed current-template load was treated as an unedited configuration")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || result.RawCompose == "" || result.IsEdit != tc.custom || result.DockerCompose != install.DockerCompose {
|
|
||||||
t.Fatalf("explicit custom state = %+v, %v; want custom=%v", result, err, tc.custom)
|
|
||||||
}
|
|
||||||
if _, err := NewIAppInstalledService().GetParams(install.ID); err != nil || calls != 1 {
|
|
||||||
t.Fatalf("configuration cache was not reused: calls=%d, %v", calls, err)
|
|
||||||
}
|
|
||||||
if isEditCompose(install) != tc.custom || calls != 1 {
|
|
||||||
t.Fatal("cached list-time comparison changed custom state or made network requests")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVllmManagedComposeDoesNotRequireOfficialTemplate(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
db, app, install, _ := composeUpgradeDB(t, server.URL)
|
|
||||||
if err := db.Model(&app).UpdateColumn("key", vllmAppKeyForUpgrade).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n command: [--managed-user-option]", 1)
|
|
||||||
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service := NewIAppInstalledService()
|
|
||||||
config, err := service.GetParams(install.ID)
|
|
||||||
if err != nil || config.RawCompose != install.DockerCompose || config.DockerCompose != install.DockerCompose || config.IsEdit {
|
|
||||||
t.Fatalf("managed configuration lost the instance template: %+v, %v", config, err)
|
|
||||||
}
|
|
||||||
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
|
|
||||||
if err != nil || len(versions) != 2 || versions[0].DockerCompose != install.DockerCompose {
|
|
||||||
t.Fatalf("managed upgrade preview lost the instance template: %+v, %v", versions, err)
|
|
||||||
}
|
|
||||||
if calls.Load() != 0 {
|
|
||||||
t.Fatalf("managed template attempted %d official resource requests", calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
|
|
||||||
"golang.org/x/text/language"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type iconSyncTransport struct {
|
|
||||||
providers.MultiNodeProvider
|
|
||||||
transport *http.Transport
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p iconSyncTransport) LoadRequestTransport() *http.Transport { return p.transport }
|
|
||||||
|
|
||||||
func iconSyncDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
if err := db.AutoMigrate(&model.Tag{}, &model.AppInstall{}, &model.AppInstallResource{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&[]model.Setting{{Key: "SystemVersion", Value: "2.4.0"}, {Key: "AppStoreSyncStatus", Value: constant.StatusSyncSuccess}}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
oldI18n := global.I18n
|
|
||||||
global.I18n = goi18n.NewLocalizer(goi18n.NewBundle(language.English), "en")
|
|
||||||
t.Cleanup(func() { global.I18n = oldI18n })
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOfficialIconSyncNeverDownloadsImages(t *testing.T) {
|
|
||||||
iconSyncDB(t)
|
|
||||||
var calls atomic.Int32
|
|
||||||
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
t.Cleanup(cdn.Close)
|
|
||||||
taskLog := &task.Task{Logger: global.LOG}
|
|
||||||
for _, oldIcon := range []string{"", "app_demo.png?etag=old", "b2xk", cdn.URL + "/old.png"} {
|
|
||||||
t.Run(oldIcon, func(t *testing.T) {
|
|
||||||
icon := cdn.URL + "/demo.png?signature=a%2Bb&v=one"
|
|
||||||
def := dto.AppDefine{Icon: icon, AppProperty: dto.AppProperty{Key: "demo", Type: "website"}, Versions: []dto.AppConfigVersion{{Name: "1.0", AppForm: dto.AppForm{}}}}
|
|
||||||
apps := getApps([]model.App{{Key: "demo", Icon: oldIcon}}, []dto.AppDefine{def}, "2.4.0", taskLog)
|
|
||||||
ctx := appSyncContext{baseRemoteUrl: cdn.URL, list: &dto.AppList{LastModified: 42}}
|
|
||||||
result := ctx.processOneApp(appWorkItem{app: apps["demo"], appDef: def})
|
|
||||||
if result.Icon != icon || len(result.Details) != 1 || result.Details[0].Version != "1.0" || calls.Load() != 0 {
|
|
||||||
t.Fatalf("sync changed metadata or fetched a CDN image: %+v, calls=%d", result, calls.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, invalid := range []string{"", "javascript:alert(1)", "https://user:secret@example.com/a.png", "//example.com/a.png", "https://"} {
|
|
||||||
def := dto.AppDefine{Icon: invalid, AppProperty: dto.AppProperty{Key: "demo"}}
|
|
||||||
apps := getApps([]model.App{{Key: "demo", Icon: "b2xk"}}, []dto.AppDefine{def}, "2.4.0", taskLog)
|
|
||||||
if apps["demo"].Icon != "b2xk" {
|
|
||||||
t.Fatalf("invalid icon %q erased history", invalid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOfficialIconSameRevisionRefreshIsReadOnlyUntilSync(t *testing.T) {
|
|
||||||
db := iconSyncDB(t)
|
|
||||||
apps := []model.App{
|
|
||||||
{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true},
|
|
||||||
{Key: "demo", Name: "original", Type: "website", Status: constant.AppNormal, Icon: "b2xk", LastModified: 7},
|
|
||||||
{Key: "future", Type: "website", Status: constant.AppNormal, Icon: "b2xk"},
|
|
||||||
{Key: "local-demo", Type: "website", Resource: constant.AppResourceLocal, Status: constant.AppNormal, Icon: "bG9jYWw="},
|
|
||||||
{Key: "custom-demo", Type: "website", Resource: constant.AppResourceCustom, Status: constant.AppNormal, Icon: "Y3VzdG9t"},
|
|
||||||
{Key: "down", Type: "website", Status: constant.AppTakeDown, Icon: "b2xk"},
|
|
||||||
}
|
|
||||||
if err := db.Create(&apps).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
detail := model.AppDetail{AppId: apps[1].ID, Version: "1.0", Params: "keep", DockerCompose: "keep", Update: false}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.First(&detail, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var imageCalls, indexCalls atomic.Int32
|
|
||||||
var failIndex atomic.Bool
|
|
||||||
list := dto.AppList{LastModified: 42}
|
|
||||||
for _, app := range apps {
|
|
||||||
version := 0.0
|
|
||||||
if app.Key == "future" {
|
|
||||||
version = 9.9
|
|
||||||
}
|
|
||||||
list.Apps = append(list.Apps, dto.AppDefine{Icon: "https://cdn.example/" + app.Key + ".png?signature=a%2Bb", AppProperty: dto.AppProperty{Key: app.Key, Version: version}})
|
|
||||||
}
|
|
||||||
var zipped bytes.Buffer
|
|
||||||
zw := zip.NewWriter(&zipped)
|
|
||||||
w, err := zw.Create("1panel.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := zw.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
|
|
||||||
_, _ = io.WriteString(w, "42")
|
|
||||||
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
|
|
||||||
indexCalls.Add(1)
|
|
||||||
if failIndex.Load() {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(zipped.Bytes())
|
|
||||||
default:
|
|
||||||
imageCalls.Add(1)
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
transport := server.Client().Transport.(*http.Transport).Clone()
|
|
||||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
|
||||||
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
|
||||||
}
|
|
||||||
oldProvider := xpack.MultiNodeProvider
|
|
||||||
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
|
|
||||||
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
|
|
||||||
global.Dir.ResourceDir = t.TempDir()
|
|
||||||
global.Dir.IconCacheDir = t.TempDir()
|
|
||||||
appCacheTestWrite(t, filepath.Join(global.Dir.IconCacheDir, "app_mysql.png"), "keep old image")
|
|
||||||
encodedList, err := json.Marshal(list)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), string(encodedList))
|
|
||||||
|
|
||||||
res, err := (AppService{}).GetAppUpdate()
|
|
||||||
if err != nil || res.CanUpdate || res.AppList == nil || indexCalls.Load() != 0 {
|
|
||||||
t.Fatalf("same revision/missing files forced a full sync: %+v, %v", res, err)
|
|
||||||
}
|
|
||||||
var original model.App
|
|
||||||
if err := db.First(&original, apps[1].ID).Error; err != nil || original.Icon != apps[1].Icon {
|
|
||||||
t.Fatalf("update check modified icon: %+v, %v", original, err)
|
|
||||||
}
|
|
||||||
var writes atomic.Int32
|
|
||||||
if err := db.Callback().Update().After("gorm:update").Register("test:icon-writes", func(tx *gorm.DB) {
|
|
||||||
if values, ok := tx.Statement.Dest.(map[string]interface{}); ok && tx.Statement.Table == "apps" && values["icon"] != nil {
|
|
||||||
writes.Add(1)
|
|
||||||
}
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var shared *appSyncContext
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if shared == nil || !shared.skipMetaSync || writes.Load() != 2 || imageCalls.Load() != 0 || indexCalls.Load() != 0 {
|
|
||||||
t.Fatalf("unexpected same revision sync: writes=%d, image calls=%d, index calls=%d, context=%+v", writes.Load(), imageCalls.Load(), indexCalls.Load(), shared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var stored model.App
|
|
||||||
if err := db.First(&stored, apps[1].ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := original
|
|
||||||
want.Icon = list.Apps[1].Icon
|
|
||||||
if !reflect.DeepEqual(stored, want) {
|
|
||||||
t.Fatalf("light refresh changed non-icon fields: got %+v, want %+v", stored, want)
|
|
||||||
}
|
|
||||||
for _, original := range apps[2:] {
|
|
||||||
stored = model.App{}
|
|
||||||
if err := db.First(&stored, original.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.Icon != original.Icon || stored.Status != original.Status {
|
|
||||||
t.Fatalf("filtered app was changed: %+v", stored)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var storedDetail model.AppDetail
|
|
||||||
if err := db.First(&storedDetail, detail.ID).Error; err != nil || !reflect.DeepEqual(storedDetail, detail) {
|
|
||||||
t.Fatalf("light refresh touched app details: %+v, %v", storedDetail, err)
|
|
||||||
}
|
|
||||||
failIndex.Store(true)
|
|
||||||
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), "{invalid")
|
|
||||||
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err == nil || writes.Load() != 2 || indexCalls.Load() != 1 {
|
|
||||||
t.Fatal("failed index read was accepted or modified icons")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppIconURLsReachListDetailAndInstalled(t *testing.T) {
|
|
||||||
db := iconSyncDB(t)
|
|
||||||
for _, icon := range []string{"https://cdn.example/icon.png?signature=a%2Bb", "app_legacy.png?etag=old", "bG9jYWw="} {
|
|
||||||
app := model.App{Key: "demo-" + icon[:4], Name: "demo", Type: "website", Status: constant.AppNormal, Icon: icon}
|
|
||||||
app.Name = app.Key
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
||||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
got, err := (AppService{}).PageApp(ctx, request.AppSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Name: app.Key})
|
|
||||||
if err != nil || len(got.Items) != 1 {
|
|
||||||
t.Fatalf("list failed: %+v, %v", got, err)
|
|
||||||
}
|
|
||||||
want := ""
|
|
||||||
if strings.HasPrefix(icon, "https:") {
|
|
||||||
want = icon
|
|
||||||
}
|
|
||||||
if got.Items[0].Icon != want {
|
|
||||||
t.Fatalf("list icon = %q, want %q", got.Items[0].Icon, want)
|
|
||||||
}
|
|
||||||
detail, err := (AppService{}).GetApp(ctx, app.Key)
|
|
||||||
if err != nil || detail.Icon != icon {
|
|
||||||
t.Fatalf("detail icon = %+v, %v", detail, err)
|
|
||||||
}
|
|
||||||
installed, err := handleInstalled([]model.AppInstall{{App: app, Status: constant.StatusRunning}}, false, false, false)
|
|
||||||
if err != nil || len(installed) != 1 || installed[0].Icon != want {
|
|
||||||
t.Fatalf("installed icon = %+v, %v", installed, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLocalAppLogoKeepsBase64Delivery(t *testing.T) {
|
|
||||||
db := iconSyncDB(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
const image = "\x89PNG\r\n\x1a\nlocal-icon"
|
|
||||||
appCacheTestWrite(t, filepath.Join(dir, "data.yml"), "additionalProperties:\n key: demo\n name: Local Demo\n type: website\n")
|
|
||||||
appCacheTestWrite(t, filepath.Join(dir, "README.md"), "local application")
|
|
||||||
appCacheTestWrite(t, filepath.Join(dir, "logo.png"), image)
|
|
||||||
app, err := handleLocalApp(dir)
|
|
||||||
if err != nil || app.Resource != constant.AppResourceLocal || app.Icon != base64.StdEncoding.EncodeToString([]byte(image)) {
|
|
||||||
t.Fatalf("local logo parsing changed: %+v, %v", app, err)
|
|
||||||
}
|
|
||||||
if err := db.Create(app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, source, etag, err := (AppService{}).GetAppIcon(app.Key)
|
|
||||||
if err != nil || string(data) != image || source != "" || etag != "" {
|
|
||||||
t.Fatalf("local logo no longer uses historical delivery: %q, %q, %q, %v", data, source, etag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -29,6 +31,7 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/utils/env"
|
"github.com/1Panel-dev/1Panel/agent/utils/env"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -156,14 +159,6 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.ContainerName = appInstall.ContainerName
|
res.ContainerName = appInstall.ContainerName
|
||||||
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
|
|
||||||
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
|
|
||||||
if app.Key == "ollama" {
|
|
||||||
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Name = appInstall.Name
|
res.Name = appInstall.Name
|
||||||
res.Version = appInstall.Version
|
res.Version = appInstall.Version
|
||||||
res.CreatedAt = appInstall.CreatedAt
|
res.CreatedAt = appInstall.CreatedAt
|
||||||
@@ -256,15 +251,6 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if install.Status == constant.StatusRestarting && (req.Operate == constant.Stop || req.Operate == constant.Delete) && !req.ForceDelete {
|
|
||||||
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(install.ID), taskRepo.WithOperate(task.TaskRestart))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
|
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
|
||||||
return buserr.New("ErrInstallDirNotFound")
|
return buserr.New("ErrInstallDirNotFound")
|
||||||
}
|
}
|
||||||
@@ -311,14 +297,13 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
|
|||||||
return syncAppInstallStatus(&install, true)
|
return syncAppInstallStatus(&install, true)
|
||||||
case constant.Upgrade:
|
case constant.Upgrade:
|
||||||
upgradeReq := request.AppInstallUpgrade{
|
upgradeReq := request.AppInstallUpgrade{
|
||||||
FeishuTransition: req.FeishuTransition,
|
InstallID: install.ID,
|
||||||
InstallID: install.ID,
|
DetailID: req.DetailId,
|
||||||
DetailID: req.DetailId,
|
Backup: req.Backup,
|
||||||
Backup: req.Backup,
|
PullImage: req.PullImage,
|
||||||
PullImage: req.PullImage,
|
DeleteImage: req.DeleteImage,
|
||||||
DeleteImage: req.DeleteImage,
|
DockerCompose: req.DockerCompose,
|
||||||
DockerCompose: req.DockerCompose,
|
TaskID: req.TaskID,
|
||||||
TaskID: req.TaskID,
|
|
||||||
}
|
}
|
||||||
return upgradeInstall(upgradeReq)
|
return upgradeInstall(upgradeReq)
|
||||||
case constant.Reload:
|
case constant.Reload:
|
||||||
@@ -348,6 +333,9 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
return errors.New("lifecycle script operation not supported")
|
return errors.New("lifecycle script operation not supported")
|
||||||
}
|
}
|
||||||
install.Message = ""
|
install.Message = ""
|
||||||
|
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
|
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -358,17 +346,10 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ready := make(chan error, 1)
|
|
||||||
started := make(chan struct{})
|
|
||||||
operation := string(req.Operate)
|
operation := string(req.Operate)
|
||||||
operationTask.AddSubTaskWithOps(
|
operationTask.AddSubTaskWithOps(
|
||||||
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
|
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
|
||||||
func(t *task.Task) error {
|
func(t *task.Task) error {
|
||||||
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
close(started)
|
|
||||||
ready <- nil
|
|
||||||
if err := runScript(t, &install, operation); err != nil {
|
if err := runScript(t, &install, operation); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -395,12 +376,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
)
|
)
|
||||||
go func() {
|
go func() {
|
||||||
if taskErr := operationTask.Execute(); taskErr != nil {
|
if taskErr := operationTask.Execute(); taskErr != nil {
|
||||||
select {
|
|
||||||
case <-started:
|
|
||||||
default:
|
|
||||||
ready <- taskErr
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if onFailure != nil {
|
if onFailure != nil {
|
||||||
onFailure(taskErr)
|
onFailure(taskErr)
|
||||||
return
|
return
|
||||||
@@ -410,7 +385,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
_ = appInstallRepo.Save(context.Background(), &install)
|
_ = appInstallRepo.Save(context.Background(), &install)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return <-ready
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
|
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
|
||||||
@@ -463,37 +438,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modelAccountBaseURL := ""
|
|
||||||
if installed.App.Key == "ollama" {
|
|
||||||
if req.SyncModelAccount != nil {
|
|
||||||
installed.SyncModelAccount = *req.SyncModelAccount
|
|
||||||
}
|
|
||||||
if req.ModelAccountBaseURLType != "" {
|
|
||||||
installed.ModelAccountBaseURLType = req.ModelAccountBaseURLType
|
|
||||||
installed.ModelAccountBaseURL = req.ModelAccountBaseURL
|
|
||||||
} else if installed.SyncModelAccount {
|
|
||||||
installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(oldInstalled)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if installed.SyncModelAccount {
|
|
||||||
containerName := installed.ContainerName
|
|
||||||
if req.Advanced && req.ContainerName != "" {
|
|
||||||
containerName = req.ContainerName
|
|
||||||
}
|
|
||||||
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, containerName, "11434", installed.HttpPort)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
backupDockerCompose := installed.DockerCompose
|
backupDockerCompose := installed.DockerCompose
|
||||||
updateGPU := installed.App.Key == "ollama" && req.GPU != nil
|
if req.Advanced {
|
||||||
if req.Advanced || updateGPU {
|
|
||||||
composeMap := make(map[string]interface{})
|
composeMap := make(map[string]interface{})
|
||||||
if req.Advanced && req.EditCompose {
|
if req.EditCompose {
|
||||||
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -502,35 +450,16 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Advanced && !req.SkipComposeCommonConfig {
|
if !req.SkipComposeCommonConfig {
|
||||||
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
|
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if updateGPU {
|
|
||||||
services, _ := composeMap["services"].(map[string]interface{})
|
|
||||||
service, ok := services[installed.ServiceName].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return buserr.New("ErrFileParse")
|
|
||||||
}
|
|
||||||
gpuParams := map[string]interface{}{}
|
|
||||||
if err := json.Unmarshal([]byte(installed.Env), &gpuParams); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for key, value := range req.Params {
|
|
||||||
gpuParams[key] = value
|
|
||||||
}
|
|
||||||
if err := ApplyModelGPU(service, req.GPU, gpuParams, "nvidia"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composeByte, err := yaml.Marshal(composeMap)
|
composeByte, err := yaml.Marshal(composeMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
installed.DockerCompose = string(composeByte)
|
installed.DockerCompose = string(composeByte)
|
||||||
}
|
|
||||||
if req.Advanced {
|
|
||||||
if req.ContainerName == "" {
|
if req.ContainerName == "" {
|
||||||
req.Params[constant.ContainerName] = installed.ContainerName
|
req.Params[constant.ContainerName] = installed.ContainerName
|
||||||
} else {
|
} else {
|
||||||
@@ -634,9 +563,6 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
|||||||
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
|
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
|
||||||
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
|
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
|
||||||
}
|
}
|
||||||
if installed.App.Key == "ollama" && installed.SyncModelAccount {
|
|
||||||
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,7 +679,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return versions, err
|
||||||
}
|
}
|
||||||
var feishuLegacy, feishuConfigured, feishuChecked bool
|
|
||||||
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
|
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return versions, err
|
||||||
@@ -778,17 +703,26 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
|
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
|
||||||
}
|
}
|
||||||
if canUpgrade {
|
if canUpgrade {
|
||||||
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
|
var newCompose string
|
||||||
if feishuTarget && !feishuChecked && (req.LoadCompose == nil || *req.LoadCompose) {
|
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
|
||||||
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
|
filename := filepath.Base(detail.DownloadUrl)
|
||||||
if readErr != nil {
|
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
|
||||||
return versions, readErr
|
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||||
|
if statusCode == http.StatusNotFound {
|
||||||
|
return versions, buserr.New("ErrAppVersionUnavailable")
|
||||||
}
|
}
|
||||||
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return versions, err
|
||||||
}
|
}
|
||||||
feishuChecked = true
|
if statusCode > 200 {
|
||||||
|
return versions, err
|
||||||
|
}
|
||||||
|
detail.DockerCompose = string(composeRes)
|
||||||
|
_ = appDetailRepo.Update(context.Background(), detail)
|
||||||
|
}
|
||||||
|
newCompose, err = getUpgradeCompose(install, detail)
|
||||||
|
if err != nil {
|
||||||
|
return versions, err
|
||||||
}
|
}
|
||||||
if app.Key == constant.AppMysql {
|
if app.Key == constant.AppMysql {
|
||||||
majorVersion := getMajorVersion(install.Version)
|
majorVersion := getMajorVersion(install.Version)
|
||||||
@@ -797,50 +731,15 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
versions = append(versions, dto.AppVersion{
|
versions = append(versions, dto.AppVersion{
|
||||||
FeishuLegacy: feishuTarget && feishuLegacy,
|
Version: detail.Version,
|
||||||
FeishuConfigured: feishuTarget && feishuConfigured,
|
DetailId: detail.ID,
|
||||||
Version: detail.Version,
|
DockerCompose: newCompose,
|
||||||
DetailId: detail.ID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(versions, func(i, j int) bool {
|
sort.Slice(versions, func(i, j int) bool {
|
||||||
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
|
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
|
||||||
})
|
})
|
||||||
if len(versions) == 0 || (req.LoadCompose != nil && !*req.LoadCompose) {
|
|
||||||
return versions, nil
|
|
||||||
}
|
|
||||||
target := 0
|
|
||||||
if req.UpdateVersion != "" {
|
|
||||||
target = -1
|
|
||||||
for i := range versions {
|
|
||||||
if versions[i].Version == req.UpdateVersion {
|
|
||||||
target = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target == -1 {
|
|
||||||
return versions, buserr.New("ErrAppVersionUnavailable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if app.Key == vllmAppKeyForUpgrade {
|
|
||||||
versions[target].DockerCompose = install.DockerCompose
|
|
||||||
return versions, nil
|
|
||||||
}
|
|
||||||
for _, detail := range details {
|
|
||||||
if detail.ID != versions[target].DetailId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
content, err := getUpgradeCompose(install, detail)
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
versions[target].DockerCompose = content
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return versions, nil
|
return versions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,28 +928,6 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
|||||||
config := getAppCommonConfig(envs)
|
config := getAppCommonConfig(envs)
|
||||||
config.DockerCompose = install.DockerCompose
|
config.DockerCompose = install.DockerCompose
|
||||||
res.Params = params
|
res.Params = params
|
||||||
res.AppKey = install.App.Key
|
|
||||||
res.Name = install.Name
|
|
||||||
res.HttpPort = install.HttpPort
|
|
||||||
res.SyncModelAccount = install.SyncModelAccount
|
|
||||||
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
|
|
||||||
res.ModelAccountBaseURL = install.ModelAccountBaseURL
|
|
||||||
if install.App.Key == "ollama" {
|
|
||||||
var project map[string]interface{}
|
|
||||||
if err := yaml.Unmarshal([]byte(install.DockerCompose), &project); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
services, _ := project["services"].(map[string]interface{})
|
|
||||||
service, ok := services[install.ServiceName].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, buserr.New("ErrFileParse")
|
|
||||||
}
|
|
||||||
res.GPU = ReadModelGPU(service, envs, "nvidia")
|
|
||||||
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.ContainerName == "" {
|
if config.ContainerName == "" {
|
||||||
config.ContainerName = install.ContainerName
|
config.ContainerName = install.ContainerName
|
||||||
}
|
}
|
||||||
@@ -1059,22 +936,9 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
|||||||
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
|
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
|
||||||
res.WebUI = install.WebUI
|
res.WebUI = install.WebUI
|
||||||
res.Type = install.App.Type
|
res.Type = install.App.Type
|
||||||
rawCompose := install.DockerCompose
|
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
|
||||||
if install.App.Key != vllmAppKeyForUpgrade {
|
res.RawCompose = rawCompose
|
||||||
if err := ensureAppDetailCompose(install.App, &detail); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rawCompose, err = getUpgradeCompose(install, detail)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
equal, err := composeEqualExceptImage(rawCompose, install.DockerCompose)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res.RawCompose = rawCompose
|
|
||||||
res.IsEdit = !equal
|
|
||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,16 +965,8 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
|
|||||||
}
|
}
|
||||||
switch appInstall.Status {
|
switch appInstall.Status {
|
||||||
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
||||||
constant.StatusStarting, constant.StatusWaiting:
|
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
|
||||||
return nil
|
return nil
|
||||||
case constant.StatusRestarting:
|
|
||||||
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
|
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
|
||||||
if !force {
|
if !force {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
|
|
||||||
return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
|
|
||||||
if !managesOpenrestySiteDirectory(app, detail) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
directory, _ := req.Params["WEBSITE_DIR"].(string)
|
|
||||||
return ensureCurrentWebsiteDirectory(directory)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureCurrentWebsiteDirectory(directory string) error {
|
|
||||||
changed := errors.New("website directory changed; inspect the installation before retrying")
|
|
||||||
if directory == "" || global.DB == nil {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
|
|
||||||
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
|
|
||||||
missing := errors.Is(err, gorm.ErrRecordNotFound)
|
|
||||||
if err != nil && !missing {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
current := setting.Value
|
|
||||||
if current == "" {
|
|
||||||
current = path.Join(global.Dir.BaseDir, "1panel", "www")
|
|
||||||
}
|
|
||||||
if directory != current {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
if missing {
|
|
||||||
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if setting.Value == "" {
|
|
||||||
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if updated != 1 {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAppStoreIndexUsesCacheUntilRevisionChanges(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
cache string
|
|
||||||
version int
|
|
||||||
failZIP bool
|
|
||||||
wantZIP int32
|
|
||||||
wantUpdate bool
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{name: "matching cache", cache: `{"lastModified":42}`, version: 42},
|
|
||||||
{name: "missing cache", version: 42, wantZIP: 1},
|
|
||||||
{name: "malformed cache", cache: `{invalid`, version: 42, wantZIP: 1},
|
|
||||||
{name: "null cache", cache: `null`, version: 42, wantZIP: 1},
|
|
||||||
{name: "trailing corrupt data", cache: `{"lastModified":42} garbage`, version: 42, wantZIP: 1},
|
|
||||||
{name: "outdated cache", cache: `{"lastModified":41}`, version: 42, wantZIP: 1},
|
|
||||||
{name: "remote revision changed", cache: `{"lastModified":42}`, version: 43, wantUpdate: true},
|
|
||||||
{name: "download failure", cache: `{invalid`, version: 42, failZIP: true, wantZIP: 1, wantErr: true},
|
|
||||||
{name: "cached minimum panel version", cache: `{"lastModified":42,"additionalProperties":{"version":"9.9.0"}}`, version: 42, wantErr: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
db := iconSyncDB(t)
|
|
||||||
if err := db.Create(&model.App{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
global.Dir.ResourceDir = t.TempDir()
|
|
||||||
cachePath := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
|
||||||
if tc.cache != "" {
|
|
||||||
appCacheTestWrite(t, cachePath, tc.cache)
|
|
||||||
}
|
|
||||||
list := dto.AppList{LastModified: tc.version, Apps: []dto.AppDefine{{Icon: "https://cdn.example/mysql.png", AppProperty: dto.AppProperty{Key: "mysql"}}}}
|
|
||||||
var archive bytes.Buffer
|
|
||||||
zw := zip.NewWriter(&archive)
|
|
||||||
entry, err := zw.Create("1panel.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(entry).Encode(list); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := zw.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var versionCalls, zipCalls atomic.Int32
|
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
|
|
||||||
versionCalls.Add(1)
|
|
||||||
_, _ = io.WriteString(w, strconv.Itoa(tc.version))
|
|
||||||
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
|
|
||||||
zipCalls.Add(1)
|
|
||||||
if tc.failZIP {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(archive.Bytes())
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected remote resource request: %s", r.URL)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
transport := server.Client().Transport.(*http.Transport).Clone()
|
|
||||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
|
||||||
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
|
||||||
}
|
|
||||||
oldProvider := xpack.MultiNodeProvider
|
|
||||||
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
|
|
||||||
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
|
|
||||||
|
|
||||||
res, err := (AppService{}).GetAppUpdate()
|
|
||||||
if (err != nil) != tc.wantErr || res == nil && !tc.wantErr {
|
|
||||||
t.Fatalf("update check = %+v, %v; want error %v", res, err, tc.wantErr)
|
|
||||||
}
|
|
||||||
if zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 1 {
|
|
||||||
t.Fatalf("first check requests: version=%d, ZIP=%d; want 1/%d", versionCalls.Load(), zipCalls.Load(), tc.wantZIP)
|
|
||||||
}
|
|
||||||
if tc.wantErr {
|
|
||||||
if tc.wantZIP == 0 && !strings.Contains(err.Error(), "ErrVersionTooLow") {
|
|
||||||
t.Fatalf("cached panel-version check was lost: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if res.CanUpdate != tc.wantUpdate {
|
|
||||||
t.Fatalf("CanUpdate=%v, want %v", res.CanUpdate, tc.wantUpdate)
|
|
||||||
}
|
|
||||||
if tc.wantUpdate {
|
|
||||||
// Full synchronization still fetches the new index after detecting a revision change.
|
|
||||||
fresh, err := getAppList()
|
|
||||||
if err != nil || fresh.LastModified != tc.version || zipCalls.Load() != 1 {
|
|
||||||
t.Fatalf("changed revision did not fetch the new index: %+v, %v, ZIP=%d", fresh, err, zipCalls.Load())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if res.AppList == nil || res.AppList.LastModified != 42 {
|
|
||||||
t.Fatalf("same-revision icon refresh lost its index: %+v", res)
|
|
||||||
}
|
|
||||||
if _, err := (AppService{}).GetAppUpdate(); err != nil || zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 2 {
|
|
||||||
t.Fatalf("second check redownloaded cached index: %v, version=%d, ZIP=%d", err, versionCalls.Load(), zipCalls.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
@@ -12,11 +17,15 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
||||||
)
|
)
|
||||||
|
|
||||||
type appSyncContext struct {
|
type appSyncContext struct {
|
||||||
task *task.Task
|
task *task.Task
|
||||||
|
httpClient http.Client
|
||||||
baseRemoteUrl string
|
baseRemoteUrl string
|
||||||
systemVersion string
|
systemVersion string
|
||||||
appsMap map[string]model.App
|
appsMap map[string]model.App
|
||||||
@@ -25,6 +34,7 @@ type appSyncContext struct {
|
|||||||
oldAppIds []uint
|
oldAppIds []uint
|
||||||
appTags []*model.AppTag
|
appTags []*model.AppTag
|
||||||
skipMetaSync bool
|
skipMetaSync bool
|
||||||
|
pendingIcons map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
||||||
@@ -41,10 +51,6 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
|
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := refreshOfficialAppIcons(updateRes.AppList, t); err != nil {
|
|
||||||
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
global.LOG.Infof("[AppStore] Appstore is up to date")
|
global.LOG.Infof("[AppStore] Appstore is up to date")
|
||||||
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
|
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
|
||||||
*sharedCtx = &appSyncContext{skipMetaSync: true}
|
*sharedCtx = &appSyncContext{skipMetaSync: true}
|
||||||
@@ -76,11 +82,13 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
|
|
||||||
ctx := &appSyncContext{
|
ctx := &appSyncContext{
|
||||||
task: t,
|
task: t,
|
||||||
|
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.MultiNodeProvider.LoadRequestTransport()},
|
||||||
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
|
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
|
||||||
systemVersion: setting.SystemVersion,
|
systemVersion: setting.SystemVersion,
|
||||||
settingService: settingService,
|
settingService: settingService,
|
||||||
list: list,
|
list: list,
|
||||||
appTags: make([]*model.AppTag, 0),
|
appTags: make([]*model.AppTag, 0),
|
||||||
|
pendingIcons: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = SyncTags(list.Extra); err != nil {
|
if err = SyncTags(list.Extra); err != nil {
|
||||||
@@ -99,7 +107,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
|
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
||||||
|
|
||||||
var addCount, updateCount, deleteCount int
|
var addCount, updateCount, deleteCount int
|
||||||
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
|
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
|
||||||
@@ -121,39 +129,40 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
|
|
||||||
if list == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
apps, err := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
systemVersion, err := settingRepo.GetValueByKey("SystemVersion")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updated := getApps(apps, list.Apps, systemVersion, t)
|
|
||||||
for _, app := range apps {
|
|
||||||
candidate := updated[app.Key]
|
|
||||||
if app.Status != constant.AppNormal || candidate.Status != constant.AppNormal || candidate.Icon == app.Icon {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := appRepo.UpdateIcon(app.ID, candidate.Icon); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type appWorkItem struct {
|
type appWorkItem struct {
|
||||||
appDef dto.AppDefine
|
appDef dto.AppDefine
|
||||||
app model.App
|
app model.App
|
||||||
|
iconUrl string
|
||||||
|
hadIcon bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
|
type appWorkResult struct {
|
||||||
|
appKey string
|
||||||
|
app model.App
|
||||||
|
iconStatus int
|
||||||
|
hadIcon bool
|
||||||
|
httpFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
||||||
app := item.app
|
app := item.app
|
||||||
l := item.appDef
|
l := item.appDef
|
||||||
|
result := appWorkResult{
|
||||||
|
appKey: l.AppProperty.Key,
|
||||||
|
hadIcon: item.hadIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.hadIcon {
|
||||||
|
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon)
|
||||||
|
result.iconStatus = status
|
||||||
|
switch status {
|
||||||
|
case http.StatusOK:
|
||||||
|
app.Icon = iconField
|
||||||
|
case http.StatusNotModified:
|
||||||
|
default:
|
||||||
|
result.httpFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.TagsKey = l.AppProperty.Tags
|
app.TagsKey = l.AppProperty.Tags
|
||||||
if l.AppProperty.Recommend > 0 {
|
if l.AppProperty.Recommend > 0 {
|
||||||
@@ -180,12 +189,20 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
if _, ok := InitTypes[app.Type]; ok {
|
||||||
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
|
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
|
||||||
|
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||||
|
if err == nil {
|
||||||
|
detail.DockerCompose = string(composeRes)
|
||||||
|
} else {
|
||||||
|
result.httpFailed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
detail.DockerCompose = ""
|
detail.DockerCompose = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
detail.Params = string(paramByte)
|
detail.Params = string(paramByte)
|
||||||
detail.DownloadUrl = downloadURL
|
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
||||||
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
|
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
|
||||||
detail.Update = true
|
detail.Update = true
|
||||||
detail.LastModified = v.LastModified
|
detail.LastModified = v.LastModified
|
||||||
@@ -198,25 +215,170 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
|
|||||||
}
|
}
|
||||||
app.Details = newDetails
|
app.Details = newDetails
|
||||||
|
|
||||||
return app
|
result.app = app
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *appSyncContext) syncAppDetails() error {
|
func (c *appSyncContext) syncAppIconsAndDetails() error {
|
||||||
total := len(c.list.Apps)
|
total := len(c.list.Apps)
|
||||||
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
|
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
|
||||||
for i, definition := range c.list.Apps {
|
|
||||||
app, ok := c.appsMap[definition.AppProperty.Key]
|
workItems := make([]appWorkItem, 0, total)
|
||||||
if ok {
|
for _, l := range c.list.Apps {
|
||||||
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
|
app, ok := c.appsMap[l.AppProperty.Key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if (i+1)%max(1, total/4) == 0 || i+1 == total {
|
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
|
||||||
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
|
workItems = append(workItems, appWorkItem{
|
||||||
|
appDef: l,
|
||||||
|
app: app,
|
||||||
|
iconUrl: iconUrl,
|
||||||
|
hadIcon: hasPending,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalWork := len(workItems)
|
||||||
|
if totalWork == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWorkers = 4
|
||||||
|
var (
|
||||||
|
failFlag atomic.Bool
|
||||||
|
workCh = make(chan int, maxWorkers)
|
||||||
|
resultCh = make(chan appWorkResult, maxWorkers)
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
wg.Add(maxWorkers)
|
||||||
|
for range maxWorkers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for idx := range workCh {
|
||||||
|
result := c.processOneApp(workItems[idx])
|
||||||
|
resultCh <- result
|
||||||
|
if result.httpFailed {
|
||||||
|
failFlag.Store(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() { wg.Wait(); close(resultCh) }()
|
||||||
|
|
||||||
|
var fed atomic.Int32
|
||||||
|
go func() {
|
||||||
|
for i := range workItems {
|
||||||
|
if failFlag.Load() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
workCh <- i
|
||||||
|
fed.Store(int32(i + 1))
|
||||||
|
}
|
||||||
|
close(workCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var (
|
||||||
|
completed int
|
||||||
|
icon200Count int
|
||||||
|
icon304Count int
|
||||||
|
iconFailCount int
|
||||||
|
)
|
||||||
|
milestones := [4]int{totalWork / 4, totalWork / 2, totalWork * 3 / 4, totalWork}
|
||||||
|
nextMS := 0
|
||||||
|
checkProgress := func() {
|
||||||
|
if nextMS < len(milestones) && completed >= milestones[nextMS] {
|
||||||
|
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), completed, totalWork)
|
||||||
|
nextMS++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total)
|
|
||||||
|
applyResult := func(result appWorkResult) {
|
||||||
|
c.appsMap[result.appKey] = result.app
|
||||||
|
if result.hadIcon {
|
||||||
|
switch result.iconStatus {
|
||||||
|
case http.StatusOK:
|
||||||
|
icon200Count++
|
||||||
|
case http.StatusNotModified:
|
||||||
|
icon304Count++
|
||||||
|
default:
|
||||||
|
iconFailCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed++
|
||||||
|
checkProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
for result := range resultCh {
|
||||||
|
applyResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fedCount := int(fed.Load())
|
||||||
|
if failFlag.Load() && fedCount < len(workItems) {
|
||||||
|
global.LOG.Warnf("[AppStore] HTTP failure detected, falling back to serial for remaining %d apps", len(workItems)-fedCount)
|
||||||
|
for i := fedCount; i < len(workItems); i++ {
|
||||||
|
result := c.processOneApp(workItems[i])
|
||||||
|
applyResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
|
||||||
|
total, icon200Count, icon304Count, iconFailCount)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
|
||||||
|
iconFileName, existingEtag := appicon.ParseIconField(oldIcon)
|
||||||
|
|
||||||
|
reqHeaders := make(map[string]string)
|
||||||
|
if existingEtag != "" && iconFileName != "" && appicon.IconFileExists(iconFileName) {
|
||||||
|
reqHeaders["If-None-Match"] = existingEtag
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := req_helper.HandleRequestWithHeaders(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s, reqHeaders)
|
||||||
|
if err != nil {
|
||||||
|
global.LOG.Warnf("[AppStore] request icon failed url=%s, err=%v", iconUrl, err)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
return http.StatusNotModified, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
global.LOG.Warnf("[AppStore] download icon failed url=%s, status=%d", iconUrl, resp.StatusCode)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Body) == 0 {
|
||||||
|
global.LOG.Warnf("[AppStore] download icon empty body url=%s", iconUrl)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Body[0] == '<' {
|
||||||
|
global.LOG.Warnf("[AppStore] download icon got HTML response url=%s", iconUrl)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
ct := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
if strings.ToLower(ct) != "image/png" {
|
||||||
|
global.LOG.Warnf("[AppStore] unexpected icon content-type: %s, expected image/png, url=%s", ct, iconUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName, err := appicon.WriteIconFile(appKey, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
global.LOG.Warnf("[AppStore] write icon file failed appKey=%s, err=%v", appKey, err)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
newEtag := resp.Header.Get("ETag")
|
||||||
|
iconField = appicon.BuildIconField(fileName, newEtag)
|
||||||
|
|
||||||
|
return http.StatusOK, iconField
|
||||||
|
}
|
||||||
|
|
||||||
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
|
||||||
return func(t *task.Task) (err error) {
|
return func(t *task.Task) (err error) {
|
||||||
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
|
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
|
||||||
@@ -245,7 +407,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
|
|||||||
ctx.task = t
|
ctx.task = t
|
||||||
ctx.appTags = make([]*model.AppTag, 0)
|
ctx.appTags = make([]*model.AppTag, 0)
|
||||||
|
|
||||||
if err = ctx.syncAppDetails(); err != nil {
|
if err = ctx.syncAppIconsAndDetails(); err != nil {
|
||||||
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
|
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,16 +60,10 @@ type upgradeFileSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type appUpgradeContext struct {
|
type appUpgradeContext struct {
|
||||||
openclawOriginal []byte
|
req request.AppInstallUpgrade
|
||||||
feishuOriginal []byte
|
original model.AppInstall
|
||||||
feishuImage string
|
candidate model.AppInstall
|
||||||
feishuOldImage string
|
detail model.AppDetail
|
||||||
feishuEnabled bool
|
|
||||||
feishuLockPath string
|
|
||||||
req request.AppInstallUpgrade
|
|
||||||
original model.AppInstall
|
|
||||||
candidate model.AppInstall
|
|
||||||
detail model.AppDetail
|
|
||||||
|
|
||||||
phase appUpgradePhase
|
phase appUpgradePhase
|
||||||
stopAttempted bool
|
stopAttempted bool
|
||||||
@@ -108,19 +102,6 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
feishuLockPath := ""
|
|
||||||
if install.App.Key == constant.AppOpenclaw {
|
|
||||||
feishuLockPath = path.Join(install.GetPath(), "data/conf/openclaw.json")
|
|
||||||
if _, busy := feishuSwitches.LoadOrStore(feishuLockPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if releaseLock {
|
|
||||||
feishuSwitches.Delete(feishuLockPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -148,7 +129,6 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := &appUpgradeContext{
|
ctx := &appUpgradeContext{
|
||||||
feishuLockPath: feishuLockPath,
|
|
||||||
req: req,
|
req: req,
|
||||||
original: install,
|
original: install,
|
||||||
candidate: install,
|
candidate: install,
|
||||||
@@ -216,9 +196,8 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
|
u.detail.DockerCompose = string(composeContent)
|
||||||
return err
|
_ = appDetailRepo.Update(context.Background(), u.detail)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
||||||
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
||||||
@@ -243,10 +222,6 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
|
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.openclawOriginal, err = os.ReadFile(path.Join(u.stageDir, "data/conf/openclaw.json"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if u.original.App.Key == constant.AppOpenresty {
|
if u.original.App.Key == constant.AppOpenresty {
|
||||||
for _, relativePath := range []string{
|
for _, relativePath := range []string{
|
||||||
@@ -355,9 +330,6 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if u.original.App.Resource == constant.AppResourceRemote {
|
if u.original.App.Resource == constant.AppResourceRemote {
|
||||||
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
|
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
|
||||||
}
|
}
|
||||||
if err = u.prepareFeishuUpgrade(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.phase = appUpgradePrepared
|
u.phase = appUpgradePrepared
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -415,9 +387,6 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
|
|||||||
}
|
}
|
||||||
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
|
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
|
||||||
u.phase = appUpgradeStopped
|
u.phase = appUpgradeStopped
|
||||||
if err := u.checkFeishuUpgradeSource(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if u.original.App.Key == constant.AppOpenresty {
|
if u.original.App.Key == constant.AppOpenresty {
|
||||||
@@ -485,9 +454,6 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
|
|||||||
} else {
|
} else {
|
||||||
t.Log("WARNING: no containers found for the upgraded application")
|
t.Log("WARNING: no containers found for the upgraded application")
|
||||||
}
|
}
|
||||||
if err = u.verifyFeishuUpgrade(t); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.candidate.Status = constant.StatusRunning
|
u.candidate.Status = constant.StatusRunning
|
||||||
u.candidate.Message = ""
|
u.candidate.Message = ""
|
||||||
|
|
||||||
@@ -613,34 +579,8 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
|
|||||||
if out != "" {
|
if out != "" {
|
||||||
err = fmt.Errorf("%s: %w", out, err)
|
err = fmt.Errorf("%s: %w", out, err)
|
||||||
}
|
}
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rollbackErr = err
|
rollbackErr = err
|
||||||
}
|
}
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
if u.backupFile == "" {
|
|
||||||
return buserr.New("ErrFeishuUpgradeBackup")
|
|
||||||
}
|
|
||||||
if err := u.restoreManagedFiles(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", "", false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := pinFeishuRollbackImage(u.original.GetComposePath(), u.feishuOldImage); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
restoredCompose, err := os.ReadFile(u.original.GetComposePath())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.original.DockerCompose = string(restoredCompose)
|
|
||||||
if _, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.finishRollback()
|
|
||||||
}
|
|
||||||
if u.backupFile != "" {
|
if u.backupFile != "" {
|
||||||
_ = u.restoreManagedFiles()
|
_ = u.restoreManagedFiles()
|
||||||
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
|
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
|
||||||
@@ -662,11 +602,6 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *appUpgradeContext) finishRollback() error {
|
func (u *appUpgradeContext) finishRollback() error {
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
restored := u.original
|
restored := u.original
|
||||||
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
|
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -708,12 +643,6 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *appUpgradeContext) cleanup() {
|
func (u *appUpgradeContext) cleanup() {
|
||||||
if u.feishuLockPath != "" {
|
|
||||||
feishuSwitches.Delete(u.feishuLockPath)
|
|
||||||
}
|
|
||||||
if u.feishuOriginal != nil && u.rollbackErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u.snapshot != nil {
|
if u.snapshot != nil {
|
||||||
u.snapshot.Cleanup()
|
u.snapshot.Cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func classifyLegacyFeishu(conf map[string]interface{}, installPath string) (legacy, configured bool, err error) {
|
|
||||||
for _, field := range []string{"channels", "channels.feishu", "plugins", "plugins.entries", "plugins.entries.openclaw-lark", "plugins.entries.feishu"} {
|
|
||||||
parts := strings.Split(field, ".")
|
|
||||||
parent := conf
|
|
||||||
for _, part := range parts[:len(parts)-1] {
|
|
||||||
parent = childMap(parent, part)
|
|
||||||
}
|
|
||||||
if raw, exists := parent[parts[len(parts)-1]]; exists {
|
|
||||||
if _, ok := raw.(map[string]interface{}); !ok {
|
|
||||||
return false, false, feishuConfigError(field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugins := childMap(conf, "plugins")
|
|
||||||
for _, key := range []string{"allow", "deny"} {
|
|
||||||
if raw, exists := plugins[key]; exists {
|
|
||||||
if _, ok := raw.([]string); ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids, ok := raw.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false, false, feishuConfigError("plugins." + key)
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
if _, ok := id.(string); !ok {
|
|
||||||
return false, false, feishuConfigError("plugins." + key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range []string{"openclaw-lark", "feishu"} {
|
|
||||||
if raw, exists := childMap(childMap(plugins, "entries"), id)["enabled"]; exists {
|
|
||||||
if _, ok := raw.(bool); !ok {
|
|
||||||
return false, false, feishuConfigError("plugins.entries." + id + ".enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if usesBundledFeishu(conf) {
|
|
||||||
return false, false, nil
|
|
||||||
}
|
|
||||||
f := getChannelConfig(conf, "feishu")
|
|
||||||
configured = len(f) > 0
|
|
||||||
if raw, exists := conf["bindings"]; exists {
|
|
||||||
bindings, ok := raw.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false, false, feishuConfigError("bindings")
|
|
||||||
}
|
|
||||||
for _, raw := range bindings {
|
|
||||||
binding, ok := raw.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false, false, feishuConfigError("bindings")
|
|
||||||
}
|
|
||||||
if match, exists := binding["match"]; exists {
|
|
||||||
if _, ok := match.(map[string]interface{}); !ok {
|
|
||||||
return false, false, feishuConfigError("bindings.match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extractStringValue(childMap(binding, "match")["channel"]) == "feishu" {
|
|
||||||
configured = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries := childMap(childMap(conf, "plugins"), "entries")
|
|
||||||
entry, selected := entries["openclaw-lark"]
|
|
||||||
if selected {
|
|
||||||
old, ok := entry.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false, configured, feishuConfigError("plugins.entries.openclaw-lark")
|
|
||||||
}
|
|
||||||
// A completed disable-only repair must not repeat on every upgrade.
|
|
||||||
if enabled, ok := old["enabled"].(bool); ok && !enabled && !configured {
|
|
||||||
return false, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
installed, err := checkPluginInstalled(installPath, "feishu")
|
|
||||||
if err != nil {
|
|
||||||
return false, configured, err
|
|
||||||
}
|
|
||||||
_, oldThread := f["threadSession"]
|
|
||||||
_, oldReply := f["replyMode"]
|
|
||||||
_, oldStreaming := f["streaming"].(bool)
|
|
||||||
return selected || installed || oldThread || oldReply || oldStreaming, configured, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareLegacyFeishu(conf map[string]interface{}, configured bool, transition *dto.AgentFeishuTransition) error {
|
|
||||||
if !configured {
|
|
||||||
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if transition == nil || !transition.Confirmed {
|
|
||||||
return buserr.New("ErrFeishuUpgradeConfirm")
|
|
||||||
}
|
|
||||||
if !validFeishuTransition(transition.GroupSessionScope, transition.RequireMention) {
|
|
||||||
return feishuConfigError("feishuTransition")
|
|
||||||
}
|
|
||||||
return convertLegacyFeishu(conf, dto.AgentFeishuSwitchReq{Streaming: transition.Streaming, GroupSessionScope: transition.GroupSessionScope, RequireMention: transition.RequireMention})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validFeishuTransition(scope, mention string) bool {
|
|
||||||
return (scope == "group" || scope == "group_sender" || scope == "group_topic" || scope == "group_topic_sender") && (mention == "true" || mention == "false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuRecoveryPayload(candidate []byte, configured bool) ([]byte, error) {
|
|
||||||
var conf map[string]interface{}
|
|
||||||
if err := json.Unmarshal(candidate, &conf); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if configured {
|
|
||||||
ensureChildMap(ensureChildMap(conf, "channels"), "feishu")["enabled"] = false
|
|
||||||
selectBundledFeishu(conf, false)
|
|
||||||
}
|
|
||||||
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
|
|
||||||
return json.MarshalIndent(conf, "", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *appUpgradeContext) prepareFeishuUpgrade() error {
|
|
||||||
if u.original.App.Key != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(u.candidate.Version) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
source := filepath.Join(u.original.GetPath(), "data/conf/openclaw.json")
|
|
||||||
staged := filepath.Join(u.stageDir, "data/conf/openclaw.json")
|
|
||||||
// The staged copy predates protocol migration and is the exact cutover comparison source.
|
|
||||||
original := u.openclawOriginal
|
|
||||||
current, err := os.ReadFile(source)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Equal(original, current) {
|
|
||||||
return buserr.New("ErrFeishuConfigChanged")
|
|
||||||
}
|
|
||||||
_, conf, err := readFeishuConfigBytes(staged)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
legacy, configured, err := classifyLegacyFeishu(conf, u.original.GetPath())
|
|
||||||
if err != nil || !legacy {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !u.req.Backup {
|
|
||||||
return buserr.New("ErrFeishuUpgradeBackup")
|
|
||||||
}
|
|
||||||
if err = prepareLegacyFeishu(conf, configured, u.req.FeishuTransition); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
project, err := docker.GetComposeProject(u.original.Name, u.original.GetPath(), []byte(u.candidate.DockerCompose), u.envContent, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(project.Services) != 1 {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
for _, service := range project.Services {
|
|
||||||
if service.Build != nil {
|
|
||||||
return buserr.New("ErrFeishuImageRequired")
|
|
||||||
}
|
|
||||||
u.feishuImage, err = bundledFeishuImage(service.Image)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configMounted := false
|
|
||||||
for _, v := range service.Volumes {
|
|
||||||
if v.Target == "/etc/localtime" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v.Type != "bind" {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
if err = feishuBackupPath(u.original.GetPath(), v.Source); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if v.Target == "/home/node/.openclaw" {
|
|
||||||
configMounted = filepath.Clean(v.Source) == filepath.Dir(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !configMounted {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cli, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
info, err := cli.ContainerInspect(ctx, u.original.ContainerName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.feishuOldImage = info.Image
|
|
||||||
for _, m := range info.Mounts {
|
|
||||||
if m.Destination == "/etc/localtime" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if string(m.Type) != "bind" {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
if err = feishuBackupPath(u.original.GetPath(), m.Source); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tar preserves symlinks: an external target would not be in the application backup.
|
|
||||||
if err = filepath.WalkDir(filepath.Join(u.original.GetPath(), "data"), func(file string, d os.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if d.Type()&os.ModeSymlink != 0 {
|
|
||||||
return feishuBackupPath(u.original.GetPath(), file)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.feishuOriginal = original
|
|
||||||
u.feishuEnabled = configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
|
|
||||||
payload, err := json.MarshalIndent(conf, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
before, err := os.ReadFile(staged)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = replaceFeishuConfig(staged, before, payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetCompose := filepath.Join(u.stageDir, "docker-compose.yml")
|
|
||||||
if err = pinFeishuRollbackImage(targetCompose, u.feishuImage); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pinned, err := os.ReadFile(targetCompose)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.candidate.DockerCompose = string(pinned)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func feishuBackupPath(root, source string) error {
|
|
||||||
root, err := filepath.EvalSymlinks(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resolved, err := filepath.EvalSymlinks(source)
|
|
||||||
if err != nil {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(root, resolved)
|
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *appUpgradeContext) checkFeishuUpgradeSource() error {
|
|
||||||
if u.feishuOriginal == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
current, err := os.ReadFile(filepath.Join(u.original.GetPath(), "data/conf/openclaw.json"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Equal(current, u.feishuOriginal) {
|
|
||||||
return buserr.New("ErrFeishuConfigChanged")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *appUpgradeContext) verifyFeishuUpgrade(t *task.Task) error {
|
|
||||||
if u.feishuOriginal == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := verifyFeishuActivation(u.candidate.ContainerName, u.feishuEnabled); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.feishuEnabled {
|
|
||||||
probeFeishuConnection(u.candidate.ContainerName, t)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeFeishuConnection(name string, t *task.Task) {
|
|
||||||
output, err := cmd.RunDockerExecWithStdout(30*time.Second, name, "openclaw", "channels", "status", "--channel", "feishu", "--probe", "--json")
|
|
||||||
if err != nil || !feishuProbeOK(output) {
|
|
||||||
t.Log(i18n.GetMsgByKey("AgentFeishuProbeWarning"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin the restored service to the actual old image, even if its registry tag moved.
|
|
||||||
func pinFeishuRollbackImage(composeFile string, image string) error {
|
|
||||||
raw, err := os.ReadFile(composeFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var conf map[string]interface{}
|
|
||||||
if err = yaml.Unmarshal(raw, &conf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
services := childMap(conf, "services")
|
|
||||||
if len(services) != 1 {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
for _, raw := range services {
|
|
||||||
service, ok := raw.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return buserr.New("ErrFeishuUpgradeMounts")
|
|
||||||
}
|
|
||||||
service["image"] = image
|
|
||||||
}
|
|
||||||
payload, err := yaml.Marshal(conf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeUpgradeFile(composeFile, payload, constant.FilePerm)
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -28,7 +27,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||||
@@ -392,27 +390,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if install.App.Key == "ollama" {
|
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
}
|
|
||||||
tx, ctx := helper.GetTxAndContext()
|
tx, ctx := helper.GetTxAndContext()
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if err = appInstallRepo.Delete(ctx, install); err != nil {
|
if err = appInstallRepo.Delete(ctx, install); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
appKey := install.App.Key
|
appKey := install.App.Key
|
||||||
if appKey == "ollama" {
|
|
||||||
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
|
|
||||||
if listErr != nil {
|
|
||||||
return listErr
|
|
||||||
}
|
|
||||||
if len(remaining) == 0 {
|
|
||||||
if err = aiRepo.DeleteAll(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isAgentAppKey(appKey) {
|
if isAgentAppKey(appKey) {
|
||||||
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
|
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
|
||||||
}
|
}
|
||||||
@@ -485,12 +468,7 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
|
|||||||
if err == nil && len(entries) == 0 {
|
if err == nil && len(entries) == 0 {
|
||||||
_ = op.DeleteDir(parentDir)
|
_ = op.DeleteDir(parentDir)
|
||||||
}
|
}
|
||||||
if err := tx.Commit().Error; err != nil {
|
tx.Commit()
|
||||||
return err
|
|
||||||
}
|
|
||||||
if install.App.Key == "ollama" || install.App.Key == "vllm" {
|
|
||||||
CleanupLocalModelAccount(install.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
|
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
|
||||||
if len(existApps) == 0 {
|
if len(existApps) == 0 {
|
||||||
@@ -913,52 +891,16 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func appDownloadURL(rawURL, version string) (string, error) {
|
|
||||||
parsedURL, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
query := parsedURL.Query()
|
|
||||||
query.Set("v", version)
|
|
||||||
parsedURL.RawQuery = query.Encode()
|
|
||||||
return parsedURL.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentAppDownloadURL(rawURL string) (string, error) {
|
|
||||||
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return appDownloadURL(rawURL, storeVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
|
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
|
||||||
if app.IsLocalApp() || app.IsCustomApp() {
|
if app.IsLocalApp() || app.IsCustomApp() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
if err != nil && appInstall != nil {
|
|
||||||
appInstall.Status = constant.StatusDownloadErr
|
|
||||||
appInstall.Message = err.Error()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
|
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
|
||||||
appDownloadDir := app.GetAppResourcePath()
|
appDownloadDir := app.GetAppResourcePath()
|
||||||
appVersionDir := path.Join(appDownloadDir, appDetail.Version)
|
appVersionDir := path.Join(appDownloadDir, appDetail.Version)
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cacheVersionPath := path.Join(appVersionDir, ".1panel-appstore-version")
|
|
||||||
if !appDetail.Update && fileOp.Stat(appVersionDir) {
|
if !appDetail.Update && fileOp.Stat(appVersionDir) {
|
||||||
if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
|
return
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if !fileOp.Stat(appDownloadDir) {
|
if !fileOp.Stat(appDownloadDir) {
|
||||||
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
|
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
|
||||||
@@ -967,14 +909,23 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
|||||||
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
|
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
|
||||||
}
|
}
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
|
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
|
||||||
} else {
|
} else {
|
||||||
logger.Printf("download app [%s] from %s", app.Name, downloadURL)
|
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
|
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
|
||||||
|
|
||||||
if err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
if appInstall != nil {
|
||||||
|
appInstall.Status = constant.StatusDownloadErr
|
||||||
|
appInstall.Message = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = files.DownloadFileWithProxy(appDetail.DownloadUrl, filePath); err != nil {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
global.LOG.Errorf("download app [%s] error %v", app.Name, err)
|
global.LOG.Errorf("download app [%s] error %v", app.Name, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -991,16 +942,8 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = fileOp.DeleteFile(filePath)
|
_ = fileOp.DeleteFile(filePath)
|
||||||
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
|
appDetail.Update = false
|
||||||
return err
|
_ = appDetailRepo.Update(context.Background(), appDetail)
|
||||||
}
|
|
||||||
marked, err := appDetailRepo.MarkDownloaded(appDetail)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !marked {
|
|
||||||
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,8 +1217,9 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
|
|||||||
return appDetails
|
return appDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
|
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
|
||||||
apps := make(map[string]model.App, len(oldApps))
|
apps := make(map[string]model.App, len(oldApps))
|
||||||
|
pendingIcons := make(map[string]string, len(items))
|
||||||
for _, old := range oldApps {
|
for _, old := range oldApps {
|
||||||
old.Status = constant.AppTakeDown
|
old.Status = constant.AppTakeDown
|
||||||
apps[old.Key] = old
|
apps[old.Key] = old
|
||||||
@@ -1316,14 +1260,12 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
|
|||||||
app.Architectures = strings.Join(config.Architectures, ",")
|
app.Architectures = strings.Join(config.Architectures, ",")
|
||||||
app.GpuSupport = config.GpuSupport
|
app.GpuSupport = config.GpuSupport
|
||||||
app.BatchInstallSupport = config.BatchInstallSupport
|
app.BatchInstallSupport = config.BatchInstallSupport
|
||||||
if appicon.IsRemoteURL(item.Icon) {
|
if item.Icon != "" {
|
||||||
app.Icon = item.Icon
|
pendingIcons[key] = item.Icon
|
||||||
} else if item.Icon != "" {
|
|
||||||
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
|
|
||||||
}
|
}
|
||||||
apps[key] = app
|
apps[key] = app
|
||||||
}
|
}
|
||||||
return apps
|
return apps, pendingIcons
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
|
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
|
||||||
@@ -1586,7 +1528,6 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
|
|||||||
HttpPort: installed.HttpPort,
|
HttpPort: installed.HttpPort,
|
||||||
HttpsPort: installed.HttpsPort,
|
HttpsPort: installed.HttpsPort,
|
||||||
AppName: installed.App.Name,
|
AppName: installed.App.Name,
|
||||||
Icon: appicon.RemoteURL(installed.App.Icon),
|
|
||||||
AppKey: installed.App.Key,
|
AppKey: installed.App.Key,
|
||||||
AppType: installed.App.Type,
|
AppType: installed.App.Type,
|
||||||
Path: installed.GetPath(),
|
Path: installed.GetPath(),
|
||||||
@@ -2041,19 +1982,8 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
|
|
||||||
if hooks != nil && hooks.PrepareSiteDir != nil {
|
|
||||||
return hooks.PrepareSiteDir(app, appDetail, req, t)
|
|
||||||
}
|
|
||||||
return handleSiteDir(app, appDetail, req, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
|
|
||||||
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
|
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
|
||||||
if managesOpenrestySiteDirectory(app, appDetail) {
|
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
|
||||||
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
|
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
|
||||||
siteDir := dir.(string)
|
siteDir := dir.(string)
|
||||||
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
|
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
|
||||||
|
|||||||
@@ -197,8 +197,7 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
|
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error {
|
||||||
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
|
|
||||||
// Validate AI Gateway backups before stopping the app, creating rollback
|
// Validate AI Gateway backups before stopping the app, creating rollback
|
||||||
// backups, or touching its files/database. Keep the validated extraction for
|
// backups, or touching its files/database. Keep the validated extraction for
|
||||||
// the restore so uploaded/encrypted archives are not unpacked twice.
|
// the restore so uploaded/encrypted archives are not unpacked twice.
|
||||||
@@ -247,15 +246,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
defer os.RemoveAll(tmpPath)
|
|
||||||
if !startAfter {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isRollback {
|
if isRollback {
|
||||||
_, _ = compose.UpWithoutBuild(install.GetComposePath())
|
_, _ = compose.UpWithoutBuild(install.GetComposePath())
|
||||||
} else {
|
} else {
|
||||||
_, _ = compose.Up(install.GetComposePath())
|
_, _ = compose.Up(install.GetComposePath())
|
||||||
}
|
}
|
||||||
|
_ = os.RemoveAll(tmpPath)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
|
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
|
||||||
@@ -356,12 +352,8 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
|
|
||||||
appDir := install.GetPath()
|
appDir := install.GetPath()
|
||||||
backPath := fmt.Sprintf("%s_bak", appDir)
|
backPath := fmt.Sprintf("%s_bak", appDir)
|
||||||
if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
|
_ = fileOp.Rename(appDir, backPath)
|
||||||
return err
|
_ = fileOp.CreateDir(appDir, constant.DirPerm)
|
||||||
}
|
|
||||||
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
|
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
|
||||||
t.LogStart(deCompressName)
|
t.LogStart(deCompressName)
|
||||||
@@ -384,10 +376,6 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
_, _ = file.WriteString(mergedEnvContent)
|
_, _ = file.WriteString(mergedEnvContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The upgrade transaction verifies readiness before saving its restored install.
|
|
||||||
if !startAfter {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
backupInstall.ID = install.ID
|
backupInstall.ID = install.ID
|
||||||
backupInstall.Status = constant.StatusRunning
|
backupInstall.Status = constant.StatusRunning
|
||||||
backupInstall.AppId = install.AppId
|
backupInstall.AppId = install.AppId
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
nginxConfFile := GetWebsiteConfigPath(*website)
|
nginxConfFile := GetWebsiteConfigPath(*website)
|
||||||
if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
|
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
|
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ type ContainerService struct{}
|
|||||||
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
|
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
|
||||||
|
|
||||||
type IContainerService interface {
|
type IContainerService interface {
|
||||||
Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
|
Page(req dto.PageContainer) (int64, interface{}, error)
|
||||||
List() []dto.ContainerOptions
|
List() []dto.ContainerOptions
|
||||||
ListByImage(imageName string) []dto.ContainerOptions
|
ListByImage(imageName string) []dto.ContainerOptions
|
||||||
LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
|
LoadStatus() (dto.ContainerStatus, error)
|
||||||
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
|
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
|
||||||
ListNetwork() ([]dto.Options, error)
|
ListNetwork() ([]dto.Options, error)
|
||||||
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
|
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
|
||||||
@@ -80,7 +80,7 @@ type IContainerService interface {
|
|||||||
ContainerUpdate(req dto.ContainerOperate) error
|
ContainerUpdate(req dto.ContainerOperate) error
|
||||||
ContainerUpgrade(req dto.ContainerUpgrade) error
|
ContainerUpgrade(req dto.ContainerUpgrade) error
|
||||||
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
|
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
|
||||||
ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
|
ContainerListStats() ([]dto.ContainerListStats, error)
|
||||||
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
|
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
|
||||||
LoadResourceLimit() (*dto.ResourceLimit, error)
|
LoadResourceLimit() (*dto.ResourceLimit, error)
|
||||||
ContainerRename(req dto.ContainerRename) error
|
ContainerRename(req dto.ContainerRename) error
|
||||||
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
|
|||||||
return &ContainerService{}
|
return &ContainerService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
|
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
|
||||||
client, err := docker.NewDockerClient()
|
client, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
|
|||||||
options.Filters = filters.NewArgs()
|
options.Filters = filters.NewArgs()
|
||||||
options.Filters.Add("label", req.Filters)
|
options.Filters.Add("label", req.Filters)
|
||||||
}
|
}
|
||||||
containers, err := client.ContainerList(ctx, options)
|
containers, err := client.ContainerList(context.Background(), options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
@@ -206,30 +206,27 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
|
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
|
||||||
var data dto.ContainerStatus
|
var data dto.ContainerStatus
|
||||||
client, err := docker.NewDockerClient()
|
client, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
if !containersOnly {
|
c := context.Background()
|
||||||
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
|
|
||||||
data.ImageCount = len(images)
|
images, _ := client.ImageList(c, image.ListOptions{All: true})
|
||||||
repo, _ := imageRepoRepo.List()
|
data.ImageCount = len(images)
|
||||||
data.RepoCount = len(repo)
|
repo, _ := imageRepoRepo.List()
|
||||||
templates, _ := composeRepo.List()
|
data.RepoCount = len(repo)
|
||||||
data.ComposeTemplateCount = len(templates)
|
templates, _ := composeRepo.List()
|
||||||
networks, _ := client.NetworkList(ctx, network.ListOptions{})
|
data.ComposeTemplateCount = len(templates)
|
||||||
data.NetworkCount = len(networks)
|
networks, _ := client.NetworkList(c, network.ListOptions{})
|
||||||
volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
|
data.NetworkCount = len(networks)
|
||||||
data.VolumeCount = len(volumes.Volumes)
|
volumes, _ := client.VolumeList(c, volume.ListOptions{})
|
||||||
data.ComposeCount = loadComposeCount(client)
|
data.VolumeCount = len(volumes.Volumes)
|
||||||
}
|
data.ComposeCount = loadComposeCount(client)
|
||||||
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
|
containers, _ := client.ContainerList(c, container.ListOptions{All: true})
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
data.ContainerCount = len(containers)
|
data.ContainerCount = len(containers)
|
||||||
for _, item := range containers {
|
for _, item := range containers {
|
||||||
switch item.State {
|
switch item.State {
|
||||||
@@ -295,48 +292,25 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
|
|||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
|
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) {
|
||||||
client, err := docker.NewDockerClient()
|
client, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
options := container.ListOptions{All: true}
|
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||||
if ids != nil {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return []dto.ContainerListStats{}, nil
|
|
||||||
}
|
|
||||||
options.Filters = filters.NewArgs()
|
|
||||||
for _, id := range ids {
|
|
||||||
options.Filters.Add("id", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list, err := client.ContainerList(ctx, options)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
datas := make([]dto.ContainerListStats, len(list))
|
datas := make([]dto.ContainerListStats, len(list))
|
||||||
jobs := make(chan int)
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for worker := 0; worker < min(8, len(list)); worker++ {
|
wg.Add(len(list))
|
||||||
wg.Add(1)
|
for i := 0; i < len(list); i++ {
|
||||||
go func() {
|
go func(index int, item container.Summary) {
|
||||||
defer wg.Done()
|
datas[index] = loadCpuAndMem(client, item.ID)
|
||||||
for index := range jobs {
|
wg.Done()
|
||||||
datas[index].ContainerID = list[index].ID
|
}(i, list[i])
|
||||||
if ctx.Err() == nil && list[index].State == "running" {
|
|
||||||
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
|
|
||||||
}
|
|
||||||
if len(list[index].Names) > 0 {
|
|
||||||
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
for index := range list {
|
|
||||||
jobs <- index
|
|
||||||
}
|
|
||||||
close(jobs)
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return datas, nil
|
return datas, nil
|
||||||
}
|
}
|
||||||
@@ -1660,10 +1634,6 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkImageExist(client *client.Client, imageItem string) bool {
|
func checkImageExist(client *client.Client, imageItem string) bool {
|
||||||
return checkImageExistContext(context.Background(), client, imageItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
|
|
||||||
if client == nil {
|
if client == nil {
|
||||||
var err error
|
var err error
|
||||||
client, err = docker.NewDockerClient()
|
client, err = docker.NewDockerClient()
|
||||||
@@ -1671,7 +1641,7 @@ func checkImageExistContext(ctx context.Context, client *client.Client, imageIte
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
images, err := client.ImageList(ctx, image.ListOptions{})
|
images, err := client.ImageList(context.Background(), image.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1713,10 +1683,6 @@ func checkImageLike(client *client.Client, imageName string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pullImages(task *task.Task, client *client.Client, imageName string) error {
|
func pullImages(task *task.Task, client *client.Client, imageName string) error {
|
||||||
return pullImagesContext(context.Background(), task, client, imageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
|
|
||||||
dockerCli := docker.NewClientWithExist(client)
|
dockerCli := docker.NewClientWithExist(client)
|
||||||
repos, err := imageRepoRepo.List()
|
repos, err := imageRepoRepo.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1724,7 +1690,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
|
|||||||
}
|
}
|
||||||
imageRepo := selectImageRepo(imageName, repos)
|
imageRepo := selectImageRepo(imageName, repos)
|
||||||
if imageRepo == nil || !imageRepo.Auth {
|
if imageRepo == nil || !imageRepo.Auth {
|
||||||
return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
|
return dockerCli.PullImageWithProcess(task, imageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
options := image.PullOptions{}
|
options := image.PullOptions{}
|
||||||
@@ -1737,7 +1703,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
|
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
|
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
|
||||||
@@ -1756,13 +1722,11 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
|
|||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
|
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
data := dto.ContainerListStats{
|
data := dto.ContainerListStats{
|
||||||
ContainerID: containerItem,
|
ContainerID: containerItem,
|
||||||
}
|
}
|
||||||
res, err := client.ContainerStats(ctx, containerItem, false)
|
res, err := client.ContainerStats(context.Background(), containerItem, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,95 +91,81 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerUpgradeConcurrency = 2
|
|
||||||
|
|
||||||
// Shared by all upgrade requests handled by this agent.
|
|
||||||
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
|
|
||||||
|
|
||||||
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
|
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
|
||||||
client, err := docker.NewDockerClient()
|
client, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ctx := context.Background()
|
||||||
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
|
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = client.Close()
|
_ = client.Close()
|
||||||
global.LOG.Errorf("new task for create container failed, err: %v", err)
|
global.LOG.Errorf("new task for create container failed, err: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
|
defer client.Close()
|
||||||
defer cancel()
|
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
|
||||||
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
|
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
|
||||||
if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
|
if !checkImageExist(client, req.Image) || req.ForcePull {
|
||||||
if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
|
if err := pullImages(taskItem, client, req.Image); err != nil {
|
||||||
return fmt.Errorf("pull image %s failed: %w", req.Image, err)
|
if !req.ForcePull {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
|
||||||
}, nil)
|
}
|
||||||
var upgradeErrors []error
|
|
||||||
for _, item := range req.Names {
|
|
||||||
item := item
|
|
||||||
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
|
|
||||||
t.Logf("----------------- %s -----------------", item)
|
|
||||||
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
|
|
||||||
defer cancel()
|
|
||||||
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
|
|
||||||
if lockErr != nil {
|
|
||||||
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
|
|
||||||
return lockErr
|
|
||||||
}
|
|
||||||
defer unlock()
|
|
||||||
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
|
|
||||||
|
|
||||||
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
|
|
||||||
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
|
|
||||||
if inspectErr != nil {
|
|
||||||
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
|
|
||||||
upgradeErrors = append(upgradeErrors, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
config := cloneContainerConfig(oldContainer.Config)
|
|
||||||
config.Image = req.Image
|
|
||||||
if config.Labels == nil {
|
|
||||||
config.Labels = make(map[string]string)
|
|
||||||
}
|
|
||||||
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
|
|
||||||
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
|
|
||||||
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
|
|
||||||
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
|
|
||||||
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
|
|
||||||
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
|
|
||||||
upgradeErrors = append(upgradeErrors, upgradeErr)
|
|
||||||
return upgradeErr
|
|
||||||
}
|
|
||||||
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
|
|
||||||
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
|
|
||||||
}, config.Tty, t, true)
|
|
||||||
if err != nil {
|
|
||||||
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
|
|
||||||
upgradeErrors = append(upgradeErrors, upgradeErr)
|
|
||||||
return upgradeErr
|
|
||||||
}
|
|
||||||
if cleanupErr != nil {
|
|
||||||
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, nil)
|
||||||
}
|
var upgradeErrors []error
|
||||||
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
|
for _, item := range req.Names {
|
||||||
return errors.Join(upgradeErrors...)
|
item := item
|
||||||
}, nil)
|
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
|
||||||
// Stage contexts own the deadlines, including a separate recovery budget.
|
t.Logf("----------------- %s -----------------", item)
|
||||||
for _, subTask := range taskItem.SubTasks {
|
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
|
||||||
subTask.Timeout = 0
|
unlock := containerOperationLock.lock(item)
|
||||||
}
|
defer unlock()
|
||||||
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
|
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
|
||||||
_ = client.Close()
|
|
||||||
return err
|
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
|
||||||
}
|
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
|
||||||
|
if inspectErr != nil {
|
||||||
|
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
|
||||||
|
upgradeErrors = append(upgradeErrors, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config := cloneContainerConfig(oldContainer.Config)
|
||||||
|
config.Image = req.Image
|
||||||
|
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
|
||||||
|
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
|
||||||
|
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
|
||||||
|
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
|
||||||
|
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
|
||||||
|
upgradeErrors = append(upgradeErrors, upgradeErr)
|
||||||
|
return upgradeErr
|
||||||
|
}
|
||||||
|
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
|
||||||
|
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
|
||||||
|
}, config.Tty, t)
|
||||||
|
if err != nil {
|
||||||
|
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
|
||||||
|
upgradeErrors = append(upgradeErrors, upgradeErr)
|
||||||
|
return upgradeErr
|
||||||
|
}
|
||||||
|
if cleanupErr != nil {
|
||||||
|
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
|
||||||
|
return errors.Join(upgradeErrors...)
|
||||||
|
}, nil)
|
||||||
|
if err := taskItem.Execute(); err != nil {
|
||||||
|
global.LOG.Error(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,16 +192,11 @@ type containerOperationMutex struct {
|
|||||||
var containerOperationLock containerOperationMutex
|
var containerOperationLock containerOperationMutex
|
||||||
|
|
||||||
type containerOperationLockEntry struct {
|
type containerOperationLockEntry struct {
|
||||||
token chan struct{}
|
mutex sync.Mutex
|
||||||
references int
|
references int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *containerOperationMutex) lock(names ...string) func() {
|
func (l *containerOperationMutex) lock(names ...string) func() {
|
||||||
unlock, _ := l.lockContext(context.Background(), names...)
|
|
||||||
return unlock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
|
|
||||||
nameSet := make(map[string]struct{}, len(names))
|
nameSet := make(map[string]struct{}, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
@@ -228,7 +209,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
sort.Strings(orderedNames)
|
sort.Strings(orderedNames)
|
||||||
if len(orderedNames) == 0 {
|
if len(orderedNames) == 0 {
|
||||||
return func() {}, nil
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
l.mutex.Lock()
|
l.mutex.Lock()
|
||||||
@@ -239,7 +220,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
for _, name := range orderedNames {
|
for _, name := range orderedNames {
|
||||||
entry := l.locks[name]
|
entry := l.locks[name]
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
|
entry = &containerOperationLockEntry{}
|
||||||
l.locks[name] = entry
|
l.locks[name] = entry
|
||||||
}
|
}
|
||||||
entry.references++
|
entry.references++
|
||||||
@@ -247,10 +228,12 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
l.mutex.Unlock()
|
l.mutex.Unlock()
|
||||||
|
|
||||||
acquired := 0
|
for _, entry := range entries {
|
||||||
release := func() {
|
entry.mutex.Lock()
|
||||||
for index := acquired - 1; index >= 0; index-- {
|
}
|
||||||
<-entries[index].token
|
return func() {
|
||||||
|
for index := len(entries) - 1; index >= 0; index-- {
|
||||||
|
entries[index].mutex.Unlock()
|
||||||
}
|
}
|
||||||
l.mutex.Lock()
|
l.mutex.Lock()
|
||||||
defer l.mutex.Unlock()
|
defer l.mutex.Unlock()
|
||||||
@@ -262,20 +245,6 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
|
||||||
select {
|
|
||||||
case entry.token <- struct{}{}:
|
|
||||||
acquired++
|
|
||||||
case <-ctx.Done():
|
|
||||||
release()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
release()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return release, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type containerNetworkAttachment struct {
|
type containerNetworkAttachment struct {
|
||||||
@@ -303,131 +272,6 @@ func switchContainer(
|
|||||||
createNew func() (container.CreateResponse, error),
|
createNew func() (container.CreateResponse, error),
|
||||||
tty bool,
|
tty bool,
|
||||||
logger containerSwitchLogger,
|
logger containerSwitchLogger,
|
||||||
) (cleanupErr error, err error) {
|
|
||||||
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const (
|
|
||||||
containerUpgradePullTimeout = 30 * time.Minute
|
|
||||||
containerUpgradeReplaceTimeout = 15 * time.Minute
|
|
||||||
containerUpgradeRecoveryTimeout = 5 * time.Minute
|
|
||||||
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reconcile against Docker's actual state: a failed request may already have
|
|
||||||
// changed names, created a container, or disconnected a network on the daemon.
|
|
||||||
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
|
|
||||||
original, err := cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect original %s: %w", old.ID, err)
|
|
||||||
}
|
|
||||||
if original.State == nil {
|
|
||||||
return fmt.Errorf("original container state unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
replacement, inspectErr := cli.ContainerInspect(ctx, name)
|
|
||||||
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
|
|
||||||
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
|
|
||||||
}
|
|
||||||
if inspectErr == nil && replacement.ID != old.ID {
|
|
||||||
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
|
|
||||||
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
|
|
||||||
}
|
|
||||||
if newID != "" && replacement.ID != newID {
|
|
||||||
return fmt.Errorf("replacement ID changed; refusing to remove it")
|
|
||||||
}
|
|
||||||
newID = replacement.ID
|
|
||||||
}
|
|
||||||
if newID != "" {
|
|
||||||
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
|
|
||||||
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
|
|
||||||
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
|
|
||||||
}
|
|
||||||
if inspectErr == nil {
|
|
||||||
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
|
|
||||||
return fmt.Errorf("replacement identity could not be confirmed")
|
|
||||||
}
|
|
||||||
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
|
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
|
||||||
return fmt.Errorf("remove replacement: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimPrefix(original.Name, "/") != name {
|
|
||||||
err = cli.ContainerRename(ctx, old.ID, name)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("restore original name: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reload after mutations; connect only networks which are actually missing.
|
|
||||||
original, err = cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
|
|
||||||
wanted := make(map[string]*network.EndpointSettings)
|
|
||||||
if primary != nil {
|
|
||||||
for n, endpoint := range primary.EndpointsConfig {
|
|
||||||
if n != "bridge" {
|
|
||||||
wanted[n] = endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for n, endpoint := range extras {
|
|
||||||
if n != "bridge" {
|
|
||||||
wanted[n] = endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var reconnectErr error
|
|
||||||
for n, endpoint := range wanted {
|
|
||||||
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
|
|
||||||
if err != nil {
|
|
||||||
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reconnectErr != nil {
|
|
||||||
return reconnectErr
|
|
||||||
}
|
|
||||||
if old.State != nil && old.State.Running {
|
|
||||||
if original.State == nil {
|
|
||||||
return fmt.Errorf("original state unavailable after recovery")
|
|
||||||
}
|
|
||||||
if !original.State.Running {
|
|
||||||
err = restartOriginalContainer(ctx, cli, old.ID)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
verified, err := cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify recovery: %w", err)
|
|
||||||
}
|
|
||||||
if strings.TrimPrefix(verified.Name, "/") != name {
|
|
||||||
return fmt.Errorf("original name was not restored")
|
|
||||||
}
|
|
||||||
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
|
|
||||||
return fmt.Errorf("original container is not running after recovery")
|
|
||||||
}
|
|
||||||
for n := range wanted {
|
|
||||||
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
|
|
||||||
return fmt.Errorf("network %s was not restored", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
|
|
||||||
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
|
|
||||||
tty bool, logger containerSwitchLogger, boundedRecovery bool,
|
|
||||||
) (cleanupErr error, err error) {
|
) (cleanupErr error, err error) {
|
||||||
if oldContainer.ID == "" {
|
if oldContainer.ID == "" {
|
||||||
return nil, fmt.Errorf("original container ID is empty")
|
return nil, fmt.Errorf("original container ID is empty")
|
||||||
@@ -437,32 +281,9 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
|
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
recoverUpgrade := func(newID string, cause error) error {
|
|
||||||
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
if newID != "" {
|
|
||||||
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
|
|
||||||
}
|
|
||||||
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
|
|
||||||
}
|
|
||||||
if recoveryErr != nil {
|
|
||||||
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
|
|
||||||
if logger != nil {
|
|
||||||
logger.Log(message)
|
|
||||||
}
|
|
||||||
return errors.Join(cause, errors.New(message))
|
|
||||||
}
|
|
||||||
return cause
|
|
||||||
}
|
|
||||||
|
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
|
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerStopOld", name, err)
|
logContainerSwitchStep(logger, "ContainerStopOld", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
|
|
||||||
}
|
|
||||||
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
|
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
|
||||||
if inspectErr == nil && current.State != nil && !current.State.Running {
|
if inspectErr == nil && current.State != nil && !current.State.Running {
|
||||||
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
||||||
@@ -477,9 +298,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
backupName := containerSwitchBackupName(oldContainer.ID)
|
backupName := containerSwitchBackupName(oldContainer.ID)
|
||||||
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
|
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
|
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
|
|
||||||
}
|
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
|
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
|
||||||
@@ -491,9 +309,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
|
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
|
||||||
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
|
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
|
||||||
if disconnectErr != nil {
|
if disconnectErr != nil {
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", disconnectErr)
|
|
||||||
}
|
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(disconnectErr, rollbackErr)
|
return nil, errors.Join(disconnectErr, rollbackErr)
|
||||||
}
|
}
|
||||||
@@ -501,17 +316,11 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
created, createErr := createNew()
|
created, createErr := createNew()
|
||||||
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
|
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, createErr)
|
|
||||||
}
|
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(createErr, rollbackErr)
|
return nil, errors.Join(createErr, rollbackErr)
|
||||||
}
|
}
|
||||||
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
|
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
|
|
||||||
}
|
|
||||||
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
|
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
|
||||||
@@ -520,9 +329,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
if wasRunning {
|
if wasRunning {
|
||||||
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
|
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
|
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
|
|
||||||
}
|
|
||||||
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
|
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
|
||||||
@@ -530,11 +336,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
|
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if boundedRecovery {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
ctx = cleanupCtx
|
|
||||||
}
|
|
||||||
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
||||||
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
|
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
|
||||||
return cleanupErr, nil
|
return cleanupErr, nil
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ type IDashboardService interface {
|
|||||||
|
|
||||||
LoadQuickOptions() []dto.QuickJump
|
LoadQuickOptions() []dto.QuickJump
|
||||||
ChangeQuick(req dto.ChangeQuicks) error
|
ChangeQuick(req dto.ChangeQuicks) error
|
||||||
ListQuickDirectories() ([]model.QuickDirectory, error)
|
|
||||||
CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error)
|
|
||||||
UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error)
|
|
||||||
DeleteQuickDirectory(req dto.QuickDirectoryDelete) error
|
|
||||||
|
|
||||||
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
|
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
|
||||||
ChangeShow(req dto.SettingUpdate) error
|
ChangeShow(req dto.SettingUpdate) error
|
||||||
@@ -401,20 +397,21 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
|
|||||||
}
|
}
|
||||||
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
|
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
|
||||||
showCount := 0
|
showCount := 0
|
||||||
quicks := make([]model.QuickJump, 0, len(req.Quicks))
|
var quicks []model.QuickJump
|
||||||
for _, item := range req.Quicks {
|
for _, item := range req.Quicks {
|
||||||
|
var quick model.QuickJump
|
||||||
if item.IsShow {
|
if item.IsShow {
|
||||||
showCount++
|
showCount++
|
||||||
}
|
}
|
||||||
quicks = append(quicks, model.QuickJump{
|
if err := copier.Copy(&quick, &item); err != nil {
|
||||||
BaseModel: model.BaseModel{ID: item.ID},
|
return err
|
||||||
IsShow: item.IsShow,
|
}
|
||||||
})
|
quicks = append(quicks, quick)
|
||||||
}
|
}
|
||||||
if showCount == 0 {
|
if showCount == 0 {
|
||||||
return buserr.New("ErrMinQuickJump")
|
return buserr.New("ErrMinQuickJump")
|
||||||
}
|
}
|
||||||
if showCount > 5 {
|
if showCount > 4 {
|
||||||
return buserr.New("ErrMaxQuickJump")
|
return buserr.New("ErrMaxQuickJump")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,19 +706,6 @@ func loadQuickJump(base *dto.DashboardBase) {
|
|||||||
quicks := launcherRepo.ListQuickJump(false)
|
quicks := launcherRepo.ListQuickJump(false)
|
||||||
for i := 0; i < len(quicks); i++ {
|
for i := 0; i < len(quicks); i++ {
|
||||||
switch quicks[i].Name {
|
switch quicks[i].Name {
|
||||||
case "ModelServices":
|
|
||||||
count, err := agentAccountRepo.Count()
|
|
||||||
if err != nil {
|
|
||||||
global.LOG.Errorf("load model service count: %v", err)
|
|
||||||
}
|
|
||||||
quicks[i].Detail = fmt.Sprintf("%d", count)
|
|
||||||
case "File":
|
|
||||||
count, err := launcherRepo.CountQuickDirectories()
|
|
||||||
if err != nil {
|
|
||||||
global.LOG.Errorf("load quick directory count: %v", err)
|
|
||||||
}
|
|
||||||
quicks[i].Detail = fmt.Sprintf("%d", count)
|
|
||||||
quicks[i].Alias = ""
|
|
||||||
case "Agent":
|
case "Agent":
|
||||||
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
|
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
|
||||||
case "Website":
|
case "Website":
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mysql"
|
"github.com/1Panel-dev/1Panel/agent/utils/mysql"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
|
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
|
||||||
@@ -128,9 +126,6 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if req.Timeout == 0 {
|
if req.Timeout == 0 {
|
||||||
req.Timeout = 30
|
req.Timeout = 30
|
||||||
}
|
}
|
||||||
@@ -185,9 +180,6 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
|
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return buserr.New("ErrCmdIllegal")
|
|
||||||
}
|
|
||||||
if req.Timeout == 0 {
|
if req.Timeout == 0 {
|
||||||
req.Timeout = 30
|
req.Timeout = 30
|
||||||
}
|
}
|
||||||
@@ -311,9 +303,6 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
|
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return buserr.New("ErrCmdIllegal")
|
|
||||||
}
|
|
||||||
switch req.Type {
|
switch req.Type {
|
||||||
case constant.AppPostgresql:
|
case constant.AppPostgresql:
|
||||||
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
|
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (u *MongodbService) UpdateDescription(req dto.UpdateDescription) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
|
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password) {
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if !isSupportedMongodbPrivilege(req.Permission) {
|
if !isSupportedMongodbPrivilege(req.Permission) {
|
||||||
@@ -146,7 +146,7 @@ func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
||||||
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
||||||
if cmd.CheckIllegal(req.Database, req.Name, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Database, req.Name, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
|
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if req.From != constant.AppResourceLocal {
|
if req.From != constant.AppResourceLocal {
|
||||||
|
|||||||
@@ -433,46 +433,7 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
||||||
creation, err := prepareMysqlCreation(req)
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = creation.request
|
|
||||||
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if err := cli.Create(creation.createInfo(version)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(req.Username) != 0 {
|
|
||||||
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, host := range creation.permissionHosts {
|
|
||||||
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.LOG.Infof("create database %s successful!", req.Name)
|
|
||||||
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &creation.item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mysqlCreation struct {
|
|
||||||
request dto.MysqlDBCreate
|
|
||||||
dbType string
|
|
||||||
permissionHosts []string
|
|
||||||
item model.DatabaseMysql
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
|
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
|
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if len(req.Username) != 0 && len(req.Password) == 0 {
|
if len(req.Username) != 0 && len(req.Password) == 0 {
|
||||||
@@ -509,13 +470,13 @@ func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
|
|||||||
return nil, errors.New("cannot set root as user name")
|
return nil, errors.New("cannot set root as user name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
createItem.MysqlName = req.Database
|
createItem.MysqlName = req.Database
|
||||||
return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
|
defer cli.Close()
|
||||||
}
|
if err := cli.Create(client.CreateInfo{
|
||||||
|
|
||||||
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
|
|
||||||
req := creation.request
|
|
||||||
return client.CreateInfo{
|
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Format: req.Format,
|
Format: req.Format,
|
||||||
Collation: req.Collation,
|
Collation: req.Collation,
|
||||||
@@ -524,46 +485,25 @@ func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
|
|||||||
Permission: req.Permission,
|
Permission: req.Permission,
|
||||||
Version: version,
|
Version: version,
|
||||||
Timeout: 300,
|
Timeout: 300,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
if len(req.Username) != 0 {
|
||||||
|
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
|
||||||
func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
|
return nil, err
|
||||||
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
}
|
||||||
txCtx := context.WithValue(ctx, constant.DB, tx)
|
for _, host := range permissionHosts {
|
||||||
if req.Username != "" {
|
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
|
||||||
key := global.CONF.Base.EncryptKey
|
return nil, err
|
||||||
if key == "" {
|
|
||||||
setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
key = setting.Value
|
|
||||||
}
|
|
||||||
password, err := encrypt.StringEncryptWithKey(req.Password, key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, host := range hosts {
|
|
||||||
user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user.Type, user.Database, user.Username, user.Host = "mysql", req.Database, req.Username, host
|
|
||||||
user.Password, user.IsDelete = password, false
|
|
||||||
if req.Description != "" {
|
|
||||||
user.Description = req.Description
|
|
||||||
}
|
|
||||||
if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
|
|
||||||
if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mysqlRepo.CreateMetadata(txCtx, item)
|
}
|
||||||
})
|
|
||||||
|
global.LOG.Infof("create database %s successful!", req.Name)
|
||||||
|
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &createItem, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
|
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
|
||||||
@@ -675,7 +615,7 @@ func (u *MysqlService) ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
|
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Password, req.Host) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Password, req.Host) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
dbs := make([]string, 0, len(req.DBs))
|
dbs := make([]string, 0, len(req.DBs))
|
||||||
@@ -878,7 +818,7 @@ func (u *MysqlService) UpdateUser(req dto.MysqlUserUpdate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Host, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if err := checkMysqlNormalUser(req.Username); err != nil {
|
if err := checkMysqlNormalUser(req.Username); err != nil {
|
||||||
@@ -931,7 +871,7 @@ func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
|
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Host, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if err := checkMysqlNormalUser(req.Username); err != nil {
|
if err := checkMysqlNormalUser(req.Username); err != nil {
|
||||||
@@ -1297,7 +1237,7 @@ func isMysqlDatabaseResourceInUseError(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
||||||
@@ -1458,14 +1398,7 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadMysqlStatus(req, app, executeSqlForMaps, executeSqlForRows)
|
statusMap, err := executeSqlForMaps(app.ContainerName, app.Key, app.Password, "show global status;")
|
||||||
}
|
|
||||||
|
|
||||||
func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo,
|
|
||||||
maps func(string, string, string, string) (map[string]string, error),
|
|
||||||
rowsQuery func(string, string, string, string) ([]string, error),
|
|
||||||
) (*dto.MysqlStatus, error) {
|
|
||||||
statusMap, err := maps(app.ContainerName, app.Key, app.Password, "show global status;")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1493,7 +1426,7 @@ func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo,
|
|||||||
if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
|
if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
|
||||||
masterStatus = "show binary log status;"
|
masterStatus = "show binary log status;"
|
||||||
}
|
}
|
||||||
rows, err := rowsQuery(app.ContainerName, app.Key, app.Password, masterStatus)
|
rows, err := executeSqlForRows(app.ContainerName, app.Key, app.Password, masterStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1598,34 +1531,6 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
||||||
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return newMysqlClientForDatabase(databaseItem, database)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
|
|
||||||
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
|
|
||||||
return nil, "", errors.New("an installed local MySQL instance is required")
|
|
||||||
}
|
|
||||||
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
local, ok := cli.(*client.Local)
|
|
||||||
if !ok {
|
|
||||||
cli.Close()
|
|
||||||
return nil, "", errors.New("an installed local MySQL instance is required")
|
|
||||||
}
|
|
||||||
return local, version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
|
|
||||||
var (
|
var (
|
||||||
dbInfo client.DBInfo
|
dbInfo client.DBInfo
|
||||||
version string
|
version string
|
||||||
@@ -1633,6 +1538,10 @@ func newMysqlClientForDatabase(databaseItem model.Database, database string) (my
|
|||||||
)
|
)
|
||||||
|
|
||||||
dbInfo.Timeout = 300
|
dbInfo.Timeout = 300
|
||||||
|
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
dbInfo.Type = databaseItem.Type
|
dbInfo.Type = databaseItem.Type
|
||||||
dbInfo.From = databaseItem.From
|
dbInfo.From = databaseItem.From
|
||||||
dbInfo.Database = database
|
dbInfo.Database = database
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
|
||||||
if req.From != "local" || ctx.Err() != nil {
|
|
||||||
return nil, errors.New("invalid MCP database target or request")
|
|
||||||
}
|
|
||||||
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
created, err := createMCPMysqlDatabase(writeContext, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
|
|
||||||
}
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
|
||||||
creation, err := prepareMysqlCreation(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer local.Close()
|
|
||||||
deadline := time.Now().Add(300 * time.Second)
|
|
||||||
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
|
|
||||||
deadline = writeDeadline.Add(-10 * time.Second)
|
|
||||||
}
|
|
||||||
sqlContext, cancel := context.WithDeadline(ctx, deadline)
|
|
||||||
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &creation.item, nil
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,7 @@ func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
|
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
|
||||||
@@ -123,7 +123,7 @@ func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
|
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) {
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ func (u *PostgresqlService) ChangePrivileges(req dto.PostgresqlPrivileges) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
cli, err := LoadPostgresqlClientByFrom(req.Database)
|
cli, err := LoadPostgresqlClientByFrom(req.Database)
|
||||||
|
|||||||
@@ -882,16 +882,6 @@ func (f *FileService) ChangeName(req request.FileRename) error {
|
|||||||
if hasInvalidFileName(req.NewName) {
|
if hasInvalidFileName(req.NewName) {
|
||||||
return buserr.New("ErrInvalidChar")
|
return buserr.New("ErrInvalidChar")
|
||||||
}
|
}
|
||||||
if req.OldName == req.NewName {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// os.Rename silently replaces an existing target on Linux, so reject the
|
|
||||||
// rename here to avoid losing the target file without confirmation.
|
|
||||||
if _, err := os.Lstat(req.NewName); err == nil {
|
|
||||||
return buserr.New("ErrFileIsExist")
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fo := files.NewFileOp()
|
fo := files.NewFileOp()
|
||||||
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
|
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
|
||||||
if err := fo.Rename(req.OldName, req.NewName); err != nil {
|
if err := fo.Rename(req.OldName, req.NewName); err != nil {
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestChangeNameRejectsExistingTarget(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
oldPath := filepath.Join(dir, "old.txt")
|
|
||||||
targetPath := filepath.Join(dir, "target.txt")
|
|
||||||
// Binary source content keeps this test independent of the file-history DB.
|
|
||||||
if err := os.WriteFile(oldPath, []byte("old\x00"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: targetPath})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected an error when the target name already exists")
|
|
||||||
}
|
|
||||||
var bizErr buserr.BusinessError
|
|
||||||
if !errors.As(err, &bizErr) || bizErr.Msg != "ErrFileIsExist" {
|
|
||||||
t.Fatalf("expected ErrFileIsExist, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(targetPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(content) != "target" {
|
|
||||||
t.Fatalf("target file was overwritten, content = %q", content)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(oldPath); err != nil {
|
|
||||||
t.Fatalf("source file should still exist after a rejected rename: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeNameRenamesToNewName(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
oldPath := filepath.Join(dir, "old")
|
|
||||||
newPath := filepath.Join(dir, "new")
|
|
||||||
if err := os.Mkdir(oldPath, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: newPath}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(newPath); err != nil {
|
|
||||||
t.Fatalf("renamed path does not exist: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("old path should be gone, stat err = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
const mcpMysqlStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again."
|
|
||||||
const mcpMysqlConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs."
|
|
||||||
|
|
||||||
var errMCPMysqlStopped = mcpapi.NewBusinessError(mcpapi.CodeMySQLStopped, mcpMysqlStoppedMessage)
|
|
||||||
var errMCPMysqlConnectionFailed = mcpapi.NewBusinessError(mcpapi.CodeMySQLConnectionFailed, mcpMysqlConnectionFailedMessage)
|
|
||||||
|
|
||||||
var mcpMysqlStoppedPattern = regexp.MustCompile(`^Error response from daemon: [Cc]ontainer [A-Za-z0-9][A-Za-z0-9_.-]{0,127} is not running$`)
|
|
||||||
var mcpMysqlConnectionPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`^ERROR 2002 \(HY000\): Can't connect to local MySQL server through socket '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
|
|
||||||
regexp.MustCompile(`^ERROR 2003 \(HY000\): Can't connect to MySQL server on '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
|
|
||||||
regexp.MustCompile(`^ERROR 1045 \(28000\): Access denied for user '[^'\r\n]{1,128}'@'[^'\r\n]{1,255}' \(using password: (YES|NO)\)$`),
|
|
||||||
}
|
|
||||||
|
|
||||||
func classifyMCPMysqlReadFailure(stderr string) error {
|
|
||||||
generic := errors.New("local MySQL status query failed")
|
|
||||||
if len(stderr) == 0 || len(stderr) > 2048 || !utf8.ValidString(stderr) {
|
|
||||||
return generic
|
|
||||||
}
|
|
||||||
warning := "mysql: [Warning] Using a password on the command line interface can be insecure."
|
|
||||||
stderr = strings.TrimPrefix(stderr, warning+"\n")
|
|
||||||
if strings.HasSuffix(stderr, "\r\n") {
|
|
||||||
stderr = strings.TrimSuffix(stderr, "\r\n")
|
|
||||||
} else {
|
|
||||||
stderr = strings.TrimSuffix(stderr, "\n")
|
|
||||||
}
|
|
||||||
if strings.IndexFunc(stderr, unicode.IsControl) >= 0 {
|
|
||||||
return generic
|
|
||||||
}
|
|
||||||
if mcpMysqlStoppedPattern.MatchString(stderr) {
|
|
||||||
return errMCPMysqlStopped
|
|
||||||
}
|
|
||||||
for _, pattern := range mcpMysqlConnectionPatterns {
|
|
||||||
if pattern.MatchString(stderr) {
|
|
||||||
return errMCPMysqlConnectionFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return generic
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os/exec"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const mcpMysqlQueryTimeout = 30 * time.Second
|
|
||||||
const mcpMysqlQueryOutputLimit = 1 << 20
|
|
||||||
|
|
||||||
func mcpLocalMysqlQuery(ctx context.Context) *gorm.DB {
|
|
||||||
return global.DB.WithContext(ctx).Table("databases AS d").
|
|
||||||
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
|
|
||||||
Joins("JOIN apps AS a ON a.id = ai.app_id").
|
|
||||||
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
|
|
||||||
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseService) ListForMCP(ctx context.Context, dbType string) ([]dto.DatabaseOption, error) {
|
|
||||||
if dbType != constant.AppMysql {
|
|
||||||
return nil, errors.New("only installed local MySQL instances are supported")
|
|
||||||
}
|
|
||||||
result := make([]dto.DatabaseOption, 0)
|
|
||||||
err := mcpLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("cannot read local MySQL instances")
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mcpMysqlReadRunner func(context.Context, string, string, string) (string, error)
|
|
||||||
|
|
||||||
func (u *MysqlService) LoadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType) (*dto.MysqlStatus, error) {
|
|
||||||
return u.loadStatusForMCP(ctx, req, runMCPMysqlRead)
|
|
||||||
}
|
|
||||||
func (u *MysqlService) loadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType, run mcpMysqlReadRunner) (*dto.MysqlStatus, error) {
|
|
||||||
if req.Type != constant.AppMysql || req.Name == "" || len(req.Name) > 128 || strings.TrimSpace(req.Name) != req.Name || strings.ContainsAny(req.Name, "\x00\r\n/\\") || ctx.Err() != nil {
|
|
||||||
return nil, errors.New("an explicitly named installed local MySQL instance is required")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, mcpMysqlQueryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
var target struct {
|
|
||||||
ID uint
|
|
||||||
ContainerName, Version, Env string
|
|
||||||
}
|
|
||||||
if err := mcpLocalMysqlQuery(ctx).Where("d.name = ?", req.Name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&target).Error; err != nil || target.ID == 0 {
|
|
||||||
return nil, errors.New("the installed local MySQL instance is unavailable")
|
|
||||||
}
|
|
||||||
var env map[string]any
|
|
||||||
if json.Unmarshal([]byte(target.Env), &env) != nil {
|
|
||||||
return nil, errors.New("local MySQL configuration is unavailable")
|
|
||||||
}
|
|
||||||
password, ok := env["PANEL_DB_ROOT_PASSWORD"].(string)
|
|
||||||
if !ok || password == "" {
|
|
||||||
return nil, errors.New("local MySQL configuration is unavailable")
|
|
||||||
}
|
|
||||||
app := &repo.RootInfo{ContainerName: target.ContainerName, Version: target.Version, Key: constant.AppMysql, Password: password}
|
|
||||||
rows := func(container, _, password, sql string) ([]string, error) {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return nil, errors.New("local MySQL status query failed")
|
|
||||||
}
|
|
||||||
output, err := run(ctx, container, password, sql)
|
|
||||||
if err != nil {
|
|
||||||
if err == errMCPMysqlStopped || err == errMCPMysqlConnectionFailed {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, errors.New("local MySQL status query failed")
|
|
||||||
}
|
|
||||||
return strings.Split(output, "\n"), nil
|
|
||||||
}
|
|
||||||
maps := func(container, dbType, password, sql string) (map[string]string, error) {
|
|
||||||
lines, err := rows(container, dbType, password, sql)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values := map[string]string{}
|
|
||||||
for _, line := range lines {
|
|
||||||
if pair := strings.Split(line, "\t"); len(pair) == 2 {
|
|
||||||
values[pair[0]] = pair[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
return loadMysqlStatus(req, app, maps, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
type mcpQueryOutput struct {
|
|
||||||
buffer bytes.Buffer
|
|
||||||
mu sync.Mutex
|
|
||||||
exceeded bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *mcpQueryOutput) Write(data []byte) (int, error) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
if len(data) > mcpMysqlQueryOutputLimit-b.buffer.Len() {
|
|
||||||
b.exceeded = true
|
|
||||||
return 0, errors.New("query output limit exceeded")
|
|
||||||
}
|
|
||||||
return b.buffer.Write(data)
|
|
||||||
}
|
|
||||||
func (b *mcpQueryOutput) Len() int {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return b.buffer.Len()
|
|
||||||
}
|
|
||||||
func (b *mcpQueryOutput) String() string {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return b.buffer.String()
|
|
||||||
}
|
|
||||||
func runMCPMysqlRead(ctx context.Context, container, password, sql string) (string, error) {
|
|
||||||
switch sql {
|
|
||||||
case "show global status;", "show master status;", "show binary log status;":
|
|
||||||
default:
|
|
||||||
return "", errors.New("unsupported MySQL monitoring query")
|
|
||||||
}
|
|
||||||
command := exec.CommandContext(ctx, "docker", "exec", container, "mysql", "-uroot", "-p"+password, "-e", sql)
|
|
||||||
output := &mcpQueryOutput{}
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
command.Stdout, command.Stderr = output, io.MultiWriter(output, &stderr)
|
|
||||||
runErr := command.Run()
|
|
||||||
if ctx.Err() != nil || output.exceeded {
|
|
||||||
return "", errors.New("local MySQL status query failed")
|
|
||||||
}
|
|
||||||
if runErr != nil {
|
|
||||||
return "", classifyMCPMysqlReadFailure(stderr.String())
|
|
||||||
}
|
|
||||||
value := strings.ReplaceAll(output.String(), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
|
|
||||||
if strings.HasPrefix(value, "ERROR ") {
|
|
||||||
return "", errors.New("local MySQL status query failed")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AppInstallService) GetAppInstallInfoForMCP(ctx context.Context, id uint) (*response.AppInstallInfo, error) {
|
|
||||||
var installed model.AppInstall
|
|
||||||
if id == 0 {
|
|
||||||
return nil, errors.New("invalid installed application")
|
|
||||||
}
|
|
||||||
err := global.DB.WithContext(ctx).Preload("App").Where("id = ?", id).First(&installed).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return &response.AppInstallInfo{Status: constant.StatusDeleted}, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("installed application metadata is unavailable")
|
|
||||||
}
|
|
||||||
var env map[string]any
|
|
||||||
if json.Unmarshal([]byte(installed.Env), &env) != nil {
|
|
||||||
return nil, errors.New("installed application metadata is unavailable")
|
|
||||||
}
|
|
||||||
result := &response.AppInstallInfo{ID: installed.ID, Name: installed.Name, Version: installed.Version, Status: installed.Status, HttpPort: installed.HttpPort, AppKey: installed.App.Key, AppPorts: []int{}}
|
|
||||||
for key, value := range env {
|
|
||||||
if !strings.Contains(strings.ToUpper(key), "PANEL_APP_PORT") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var port int
|
|
||||||
switch item := value.(type) {
|
|
||||||
case string:
|
|
||||||
port, _ = strconv.Atoi(item)
|
|
||||||
case float64:
|
|
||||||
if item == float64(int(item)) {
|
|
||||||
port = int(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port > 0 && port <= 65535 {
|
|
||||||
result.AppPorts = append(result.AppPorts, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Ints(result.AppPorts)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConfigurePanelMCPTasks() error {
|
|
||||||
return taskscope.Configure(taskscope.Rule{Scope: constant.TaskScopePanelMCP, Restricted: true, ExternalExecutor: true})
|
|
||||||
}
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/net/idna"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
mcpWebsitePrivateDir = ".1panel-mcp"
|
|
||||||
mcpWebsiteProxyName = "proxy.conf"
|
|
||||||
mcpWebsiteOwnerName = "binding.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
var mcpWebsiteMutation sync.Mutex
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
configureWebsiteManagedConfig(websiteManagedConfigPolicy{
|
|
||||||
protectedDirectories: func() []string {
|
|
||||||
return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)}
|
|
||||||
},
|
|
||||||
backupIncludes: mcpWebsiteBackupIncludes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsiteBindingService struct {
|
|
||||||
createWebsite func(request.WebsiteCreate) error
|
|
||||||
updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error
|
|
||||||
applyInclude func(model.Website) error
|
|
||||||
removeInclude func(model.Website) error
|
|
||||||
checkReload func() error
|
|
||||||
openresty func() (model.AppInstall, error)
|
|
||||||
now func() time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMCPWebsiteBindingService() *MCPWebsiteBindingService {
|
|
||||||
s := &MCPWebsiteBindingService{
|
|
||||||
createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) },
|
|
||||||
updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error {
|
|
||||||
if req.Enable {
|
|
||||||
current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID)
|
|
||||||
if current.Enable {
|
|
||||||
req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm
|
|
||||||
req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3
|
|
||||||
req.HttpConfig = current.HttpConfig
|
|
||||||
} else {
|
|
||||||
defaults.WebsiteID = req.WebsiteID
|
|
||||||
req = defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
checkReload: func() error {
|
|
||||||
installed, err := getAppInstallByKey(constant.AppOpenresty)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if installed.Status != constant.StatusRunning {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return opNginx(installed.ContainerName, constant.NginxReload)
|
|
||||||
},
|
|
||||||
openresty: mcpWebsiteOpenresty,
|
|
||||||
now: time.Now,
|
|
||||||
}
|
|
||||||
s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) }
|
|
||||||
s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) }
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteOpenresty() (model.AppInstall, error) {
|
|
||||||
installed, err := getAppInstallByKey(constant.AppOpenresty)
|
|
||||||
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
return installed, err
|
|
||||||
}
|
|
||||||
client, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
inspected, err := client.ContainerInspect(ctx, installed.ContainerName)
|
|
||||||
if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) {
|
|
||||||
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
|
|
||||||
}
|
|
||||||
return installed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool {
|
|
||||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, mount := range mounts {
|
|
||||||
if filepath.Clean(mount.Destination) != "/www" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source)
|
|
||||||
if sourceErr == nil && resolvedSource == resolvedRoot {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type mcpWebsiteOwner struct {
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
KeyHash string `json:"keyHash"`
|
|
||||||
BindHash string `json:"bindHash"`
|
|
||||||
ProxyHash string `json:"proxyHash"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
RequestHash string `json:"requestHash"`
|
|
||||||
Operation string `json:"operation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteHash(value string) string {
|
|
||||||
digest := sha256.Sum256([]byte(value))
|
|
||||||
return hex.EncodeToString(digest[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID }
|
|
||||||
func mcpWebsitePrivatePath(site model.Website, name string) string {
|
|
||||||
return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteInclude(site model.Website) string {
|
|
||||||
return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteBackupIncludes(site model.Website) []string {
|
|
||||||
bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-")
|
|
||||||
if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{mcpWebsiteInclude(site)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsitePrivateDirectory(site model.Website, create bool) error {
|
|
||||||
for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} {
|
|
||||||
info, err := os.Lstat(directory)
|
|
||||||
if os.IsNotExist(err) && create {
|
|
||||||
if err = os.Mkdir(directory, 0700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
info, err = os.Lstat(directory)
|
|
||||||
}
|
|
||||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
|
|
||||||
return errors.New("invalid MCP website private directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error {
|
|
||||||
configPath := GetSitePath(site, SiteConf)
|
|
||||||
old, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
config, err := parser.NewStringParser(string(old)).Parse()
|
|
||||||
if err != nil || len(config.FindServers()) == 0 {
|
|
||||||
return errors.New("invalid website configuration")
|
|
||||||
}
|
|
||||||
server := config.FindServers()[0]
|
|
||||||
include := mcpWebsiteInclude(site)
|
|
||||||
for _, directive := range server.FindDirectives("include") {
|
|
||||||
parameters := directive.GetParameters()
|
|
||||||
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
|
|
||||||
server.RemoveDirective("include", parameters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if enabled {
|
|
||||||
server.UpdateDirective("include", []string{include})
|
|
||||||
}
|
|
||||||
updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
|
|
||||||
if string(updated) == string(old) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = s.checkReload(); err != nil {
|
|
||||||
_ = mcpWebsiteAtomicWrite(configPath, old)
|
|
||||||
_ = s.checkReload()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteRemark(req request.MCPWebsiteBinding) string {
|
|
||||||
return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding {
|
|
||||||
code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable"
|
|
||||||
if definitive {
|
|
||||||
code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration"
|
|
||||||
}
|
|
||||||
if conflict {
|
|
||||||
code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed"
|
|
||||||
}
|
|
||||||
return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validMCPWebsiteID(value string) bool {
|
|
||||||
parsed, err := uuid.Parse(value)
|
|
||||||
return err == nil && parsed != uuid.Nil && parsed.String() == value
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteDomain(value string) (string, int, error) {
|
|
||||||
if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
host, port := value, 0
|
|
||||||
if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil {
|
|
||||||
host = parsedHost
|
|
||||||
port, err = strconv.Atoi(parsedPort)
|
|
||||||
if err != nil || port < 1 || port > 65535 {
|
|
||||||
return "", 0, errors.New("invalid port")
|
|
||||||
}
|
|
||||||
} else if strings.ContainsAny(value, ":[]") {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
host, err := idna.Lookup.ToASCII(strings.ToLower(host))
|
|
||||||
if err != nil || len(host) > 253 || net.ParseIP(host) != nil {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
for _, label := range strings.Split(host, ".") {
|
|
||||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
for _, char := range label {
|
|
||||||
if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return host, port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Core can select a locally bound panel IP, but an external upstream is never
|
|
||||||
// accepted even on this private API. Host/SNI is a literal hostname, not Nginx code.
|
|
||||||
func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool {
|
|
||||||
if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u, err := url.Parse(req.Upstream)
|
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(u.Hostname())
|
|
||||||
if ip == nil || ip.IsUnspecified() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
port, err := strconv.Atoi(u.Port())
|
|
||||||
if err != nil || port < 1 || port > 65535 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
local := ip.IsLoopback()
|
|
||||||
if !local {
|
|
||||||
addresses, err := net.InterfaceAddrs()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, address := range addresses {
|
|
||||||
if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) {
|
|
||||||
local = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !local || len(req.ProxyKey) != 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err = hex.DecodeString(req.ProxyKey); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := req.CoreHost
|
|
||||||
if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil {
|
|
||||||
host = parsed
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
||||||
host = strings.Trim(host, "[]")
|
|
||||||
}
|
|
||||||
if net.ParseIP(host) == nil {
|
|
||||||
if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte {
|
|
||||||
host := req.CoreHost
|
|
||||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
host = parsed
|
|
||||||
}
|
|
||||||
host = strings.Trim(host, "[]")
|
|
||||||
var tlsDirectives string
|
|
||||||
if strings.HasPrefix(req.Upstream, "https://") {
|
|
||||||
tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n"
|
|
||||||
}
|
|
||||||
return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" +
|
|
||||||
"location = /mcp {\n" +
|
|
||||||
" proxy_pass " + req.Upstream + ";\n" +
|
|
||||||
" proxy_http_version 1.1;\n" +
|
|
||||||
" proxy_set_header Host " + req.CoreHost + ";\n" +
|
|
||||||
" proxy_set_header Connection \"\";\n" +
|
|
||||||
" proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" +
|
|
||||||
" proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" +
|
|
||||||
" proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" +
|
|
||||||
" proxy_set_header X-Real-IP $http_x_real_ip;\n" +
|
|
||||||
" proxy_set_header Forwarded $http_forwarded;\n" +
|
|
||||||
" proxy_set_header Authorization $http_authorization;\n" +
|
|
||||||
" proxy_set_header Origin $http_origin;\n" +
|
|
||||||
" proxy_set_header Accept $http_accept;\n" +
|
|
||||||
" proxy_set_header Content-Type $http_content_type;\n" +
|
|
||||||
" proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" +
|
|
||||||
" proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" +
|
|
||||||
" proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" +
|
|
||||||
" proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool {
|
|
||||||
certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey))
|
|
||||||
if err != nil || len(pair.Certificate) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
|
||||||
if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(leaf.ExtKeyUsage) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, usage := range leaf.ExtKeyUsage {
|
|
||||||
if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) {
|
|
||||||
var owner mcpWebsiteOwner
|
|
||||||
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
|
|
||||||
return owner, err
|
|
||||||
}
|
|
||||||
path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 {
|
|
||||||
return owner, errors.New("invalid MCP website ownership")
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || json.Unmarshal(data, &owner) != nil {
|
|
||||||
return owner, errors.New("invalid MCP website ownership")
|
|
||||||
}
|
|
||||||
return owner, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error {
|
|
||||||
data, err := json.Marshal(owner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteAtomicWrite(path string, data []byte) error {
|
|
||||||
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
|
|
||||||
return errors.New("managed file is not regular")
|
|
||||||
} else if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(temp.Name())
|
|
||||||
if err = temp.Chmod(0600); err == nil {
|
|
||||||
_, err = temp.Write(data)
|
|
||||||
}
|
|
||||||
if closeErr := temp.Close(); err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(temp.Name(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) {
|
|
||||||
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 {
|
|
||||||
return nil, errors.New("managed proxy is missing or invalid")
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash {
|
|
||||||
return nil, errors.New("managed proxy was modified")
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteReadProxy(path string) ([]byte, error) {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() || info.Size() > 16<<10 {
|
|
||||||
return nil, errors.New("managed proxy is not a regular file")
|
|
||||||
}
|
|
||||||
return os.ReadFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string {
|
|
||||||
return mcpWebsiteHash(fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string {
|
|
||||||
return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool {
|
|
||||||
host, port, err := mcpWebsiteDomain(req.Domain)
|
|
||||||
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
installed, installErr := s.openresty()
|
|
||||||
if installErr != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
port = installed.HttpPort
|
|
||||||
if req.EnableSSL {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, domain := range site.Domains {
|
|
||||||
if domain.Domain == host && domain.Port == port {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) {
|
|
||||||
if req.WebsiteID > 0 {
|
|
||||||
return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
|
|
||||||
}
|
|
||||||
return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteProxyIncluded(site model.Website) bool {
|
|
||||||
server, err := getServer(site)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
include := mcpWebsiteInclude(site)
|
|
||||||
for _, directive := range server.FindDirectives("include") {
|
|
||||||
parameters := directive.GetParameters()
|
|
||||||
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding {
|
|
||||||
result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID,
|
|
||||||
Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status}
|
|
||||||
if !result.EnableSSL {
|
|
||||||
result.SSLID = 0
|
|
||||||
}
|
|
||||||
if owner.Operation == "unbind" {
|
|
||||||
result.State = "unbound"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil {
|
|
||||||
result.State, result.Reason = "unavailable", "proxy_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash {
|
|
||||||
result.State, result.Reason = "unavailable", "upstream_changed"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if site.Status != constant.WebRunning {
|
|
||||||
result.State, result.Reason = "unavailable", "website_stopped"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if !mcpWebsiteProxyIncluded(site) {
|
|
||||||
result.State, result.Reason = "unavailable", "proxy_not_loaded"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
host, _, err := mcpWebsiteDomain(site.PrimaryDomain)
|
|
||||||
if err != nil {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) {
|
|
||||||
result.State, result.Reason = "unavailable", "certificate_invalid"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
installed, err := s.openresty()
|
|
||||||
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
result.State, result.Reason = "unavailable", "openresty_unavailable"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
matching, matchingSSL := 0, 0
|
|
||||||
for _, domain := range site.Domains {
|
|
||||||
if domain.Domain != host || domain.Port < 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if matching == 0 || !domain.SSL {
|
|
||||||
matching = domain.Port
|
|
||||||
}
|
|
||||||
if domain.SSL {
|
|
||||||
matchingSSL = domain.Port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matching == 0 {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
port := matching
|
|
||||||
if result.EnableSSL {
|
|
||||||
port = matchingSSL
|
|
||||||
if port == 0 {
|
|
||||||
plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort)
|
|
||||||
if slices.Contains(plan.httpsPorts, installed.HttpsPort) {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
} else if len(plan.httpsPorts) > 0 {
|
|
||||||
port = plan.httpsPorts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
scheme := "http"
|
|
||||||
if result.EnableSSL {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
address := host
|
|
||||||
if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 {
|
|
||||||
address = net.JoinHostPort(host, strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) {
|
|
||||||
mcpWebsiteMutation.Lock()
|
|
||||||
defer mcpWebsiteMutation.Unlock()
|
|
||||||
mutationStarted := false
|
|
||||||
defer func() {
|
|
||||||
if mutationStarted && result.Error != "" {
|
|
||||||
result.Definitive = false
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) {
|
|
||||||
_, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
|
|
||||||
_, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID))
|
|
||||||
mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr)
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return mcpWebsiteFailure("request_cancelled", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" {
|
|
||||||
return mcpWebsiteFailure("invalid_operation", true, false)
|
|
||||||
}
|
|
||||||
if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" {
|
|
||||||
return response.MCPWebsiteBinding{State: "unbound"}
|
|
||||||
}
|
|
||||||
if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) {
|
|
||||||
return mcpWebsiteFailure("invalid_configuration", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) {
|
|
||||||
return mcpWebsiteFailure("invalid_request", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" {
|
|
||||||
host, port, err := mcpWebsiteDomain(req.Domain)
|
|
||||||
if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
|
|
||||||
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
|
|
||||||
}
|
|
||||||
req.Domain = host
|
|
||||||
if port != 0 {
|
|
||||||
req.Domain = net.JoinHostPort(host, strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
site, err := s.load(req)
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
if req.Operation == "status" || req.Operation == "unbind" {
|
|
||||||
if req.WebsiteID == 0 || req.Operation == "unbind" {
|
|
||||||
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
|
|
||||||
}
|
|
||||||
return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"}
|
|
||||||
}
|
|
||||||
if req.Operation != "bind" {
|
|
||||||
return mcpWebsiteFailure("website_missing", true, true)
|
|
||||||
}
|
|
||||||
installed, installErr := s.openresty()
|
|
||||||
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
return mcpWebsiteFailure("openresty_unavailable", true, false)
|
|
||||||
}
|
|
||||||
group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault())
|
|
||||||
if groupErr != nil || group.ID == 0 {
|
|
||||||
return mcpWebsiteFailure("website_group_unavailable", true, false)
|
|
||||||
}
|
|
||||||
domain, port, _ := mcpWebsiteDomain(req.Domain)
|
|
||||||
if port == 0 {
|
|
||||||
port = installed.HttpPort
|
|
||||||
if req.EnableSSL {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if existing, conflictErr := websiteDomainRepo.GetFirst(websiteDomainRepo.WithDomain(domain), websiteDomainRepo.WithPort(port)); conflictErr == nil && existing.ID > 0 {
|
|
||||||
return mcpWebsiteFailure("domain_conflict", true, true)
|
|
||||||
} else if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
|
|
||||||
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: req.EnableSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
|
|
||||||
mutationStarted = true
|
|
||||||
if createErr := s.createWebsite(create); createErr != nil {
|
|
||||||
return mcpWebsiteFailure("website_create_failed", false, false)
|
|
||||||
}
|
|
||||||
site, err = s.load(req)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if site.Alias != mcpWebsiteAlias(req.BindingID) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
owner, ownerErr := mcpWebsiteReadOwner(site)
|
|
||||||
if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" {
|
|
||||||
mutationStarted = true
|
|
||||||
}
|
|
||||||
if req.Operation == "update" {
|
|
||||||
if req.Domain != "" && req.Domain != site.PrimaryDomain {
|
|
||||||
return mcpWebsiteFailure("domain_changed", true, true)
|
|
||||||
}
|
|
||||||
req.Domain = site.PrimaryDomain
|
|
||||||
host, _, domainErr := mcpWebsiteDomain(req.Domain)
|
|
||||||
if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
|
|
||||||
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
|
|
||||||
return mcpWebsiteFailure("website_directory_changed", true, true)
|
|
||||||
} else if dirErr != nil && !os.IsNotExist(dirErr) {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if ownerErr != nil {
|
|
||||||
if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" && !s.matchesInitialSite(req, site) {
|
|
||||||
return mcpWebsiteFailure("binding_request_changed", true, true)
|
|
||||||
}
|
|
||||||
owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)}
|
|
||||||
} else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
if req.Operation == "status" {
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
operationHash := mcpWebsitePayloadHash(req)
|
|
||||||
if owner.RequestID == req.RequestID && req.RequestID != "" {
|
|
||||||
if owner.RequestHash != operationHash {
|
|
||||||
return mcpWebsiteFailure("operation_request_changed", true, true)
|
|
||||||
}
|
|
||||||
if owner.Operation == "unbind" || owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) {
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
|
|
||||||
return mcpWebsiteFailure("binding_request_changed", true, true)
|
|
||||||
}
|
|
||||||
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
|
|
||||||
return mcpWebsiteFailure("proxy_not_loaded", true, true)
|
|
||||||
}
|
|
||||||
proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
|
|
||||||
if err = mcpWebsitePrivateDirectory(site, true); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_directory_changed", true, true)
|
|
||||||
}
|
|
||||||
if ownerErr != nil {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var oldProxy []byte
|
|
||||||
if owner.ProxyHash != "" && owner.Operation != "unbind" {
|
|
||||||
oldProxy, err = mcpWebsiteCurrentProxy(site, owner)
|
|
||||||
if err != nil {
|
|
||||||
actual, readErr := mcpWebsiteReadProxy(proxyPath)
|
|
||||||
if req.Operation == "unbind" && os.IsNotExist(readErr) {
|
|
||||||
oldProxy = nil
|
|
||||||
} else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) {
|
|
||||||
oldProxy = actual
|
|
||||||
} else {
|
|
||||||
return mcpWebsiteFailure("proxy_modified", true, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if _, statErr := os.Lstat(proxyPath); statErr == nil {
|
|
||||||
actual, readErr := mcpWebsiteReadProxy(proxyPath)
|
|
||||||
if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) {
|
|
||||||
return mcpWebsiteFailure("proxy_already_exists", true, true)
|
|
||||||
}
|
|
||||||
oldProxy = actual
|
|
||||||
} else if !os.IsNotExist(statErr) {
|
|
||||||
return mcpWebsiteFailure("proxy_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if req.Operation == "unbind" {
|
|
||||||
if mcpWebsiteProxyIncluded(site) {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = s.removeInclude(site); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_reload_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(oldProxy) > 0 {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = os.Remove(proxyPath); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_remove_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
if req.Operation == "update" {
|
|
||||||
if req.Domain != site.PrimaryDomain {
|
|
||||||
return mcpWebsiteFailure("domain_changed", true, true)
|
|
||||||
}
|
|
||||||
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
|
|
||||||
return mcpWebsiteFailure("certificate_update_failed", false, false)
|
|
||||||
}
|
|
||||||
site, err = s.load(req)
|
|
||||||
if err != nil {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newProxy := mcpWebsiteProxy(req)
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_write_failed", false, false)
|
|
||||||
}
|
|
||||||
if err = s.applyInclude(site); err != nil {
|
|
||||||
if len(oldProxy) == 0 {
|
|
||||||
_ = os.Remove(proxyPath)
|
|
||||||
} else {
|
|
||||||
_ = mcpWebsiteAtomicWrite(proxyPath, oldProxy)
|
|
||||||
}
|
|
||||||
_ = s.checkReload()
|
|
||||||
return mcpWebsiteFailure("proxy_reload_failed", false, false)
|
|
||||||
}
|
|
||||||
owner.ProxyHash = mcpWebsiteHash(string(newProxy))
|
|
||||||
if req.Operation != "sync" || req.RequestID != "" {
|
|
||||||
owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation
|
|
||||||
}
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/re"
|
|
||||||
"github.com/compose-spec/compose-go/v2/interpolation"
|
|
||||||
"github.com/compose-spec/compose-go/v2/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LoadModelGPUDevices() dto.ModelGPUDevices {
|
|
||||||
result := dto.ModelGPUDevices{Devices: []dto.ModelGPUDevice{}, Warnings: []string{}}
|
|
||||||
ok, client := accelerator.New()
|
|
||||||
if !ok {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
snapshot, err := client.Collect(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
result.Warnings = append(result.Warnings, err.Error())
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result.Warnings = append(result.Warnings, snapshot.Info.Warnings...)
|
|
||||||
for _, monitored := range snapshot.Devices {
|
|
||||||
if monitored.ParentID != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
device := dto.ModelGPUDevice{Available: true}
|
|
||||||
devicePath := ""
|
|
||||||
switch {
|
|
||||||
case monitored.GPU != nil:
|
|
||||||
if monitored.Vendor == "nvidia" && !re.GetRegex(re.NvidiaGPUUUIDPattern).MatchString(monitored.GPU.UUID) {
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("invalid NVIDIA GPU UUID from nvidia-smi: %s", monitored.GPU.UUID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
device.ID = monitored.ID
|
|
||||||
device.Name = fmt.Sprintf("GPU %d · %s", monitored.Index, monitored.Name)
|
|
||||||
device.Type = monitored.Vendor
|
|
||||||
device.UUID = monitored.GPU.UUID
|
|
||||||
case monitored.XPU != nil:
|
|
||||||
devicePath = monitored.XPU.Basic.DrmDevice
|
|
||||||
if !re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(devicePath) {
|
|
||||||
directory := filepath.Join("/sys/bus/pci/devices", filepath.Base(monitored.BusID), "drm")
|
|
||||||
entries, err := os.ReadDir(directory)
|
|
||||||
if err != nil {
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("resolve Intel GPU %d render device: %v", monitored.Index, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
devicePath = ""
|
|
||||||
for _, entry := range entries {
|
|
||||||
if re.GetRegex(re.IntelGPURenderNamePattern).MatchString(entry.Name()) {
|
|
||||||
devicePath = filepath.Join("/dev/dri", entry.Name())
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if devicePath == "" {
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Intel GPU %d has no render device", monitored.Index))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
device.ID = "intel:" + devicePath
|
|
||||||
device.Name = fmt.Sprintf("XPU %d · %s", monitored.Index, monitored.Name)
|
|
||||||
device.Type = "intel"
|
|
||||||
case monitored.NPU != nil:
|
|
||||||
device.ID = fmt.Sprintf("ascend:%d", monitored.Index)
|
|
||||||
device.Name = fmt.Sprintf("NPU %d · Chip %d · %s", monitored.NPUIndex, monitored.ChipIndex, monitored.Name)
|
|
||||||
device.Type = "ascend"
|
|
||||||
devicePath = fmt.Sprintf("/dev/davinci%d", monitored.Index)
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if devicePath != "" {
|
|
||||||
info, err := os.Stat(devicePath)
|
|
||||||
device.Available = err == nil && info.Mode()&os.ModeCharDevice != 0
|
|
||||||
if err != nil {
|
|
||||||
result.Warnings = append(result.Warnings, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.Devices = append(result.Devices, device)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func ApplyModelGPU(service map[string]interface{}, config *dto.ModelGPUConfig, params map[string]interface{}, imageType string) error {
|
|
||||||
if config == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := validateModelGPU(config, imageType); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resolved, err := resolveModelGPUService(service, params, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
devicePaths := []string{}
|
|
||||||
ids := []string{}
|
|
||||||
logicalIDs := []string{}
|
|
||||||
if config.Enabled && imageType != "nvidia" {
|
|
||||||
for _, deviceID := range config.DeviceIDs {
|
|
||||||
id := strings.TrimPrefix(deviceID, imageType+":")
|
|
||||||
if !slices.Contains(ids, id) {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imageType == "ascend" {
|
|
||||||
slices.SortFunc(ids, func(a, b string) int {
|
|
||||||
left, _ := strconv.Atoi(a)
|
|
||||||
right, _ := strconv.Atoi(b)
|
|
||||||
return left - right
|
|
||||||
})
|
|
||||||
visibleIDs := ids
|
|
||||||
if config.Mode == "all" || resolved["privileged"] == true || resolved["privileged"] == "true" {
|
|
||||||
visibleIDs = loadModelAscendDeviceIDs()
|
|
||||||
if len(visibleIDs) == 0 {
|
|
||||||
return fmt.Errorf("no available Ascend devices")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Mode == "all" {
|
|
||||||
ids = visibleIDs
|
|
||||||
} else {
|
|
||||||
for _, id := range ids {
|
|
||||||
index := slices.Index(visibleIDs, id)
|
|
||||||
if index < 0 {
|
|
||||||
return fmt.Errorf("Ascend GPU is unavailable: %s", id)
|
|
||||||
}
|
|
||||||
logicalIDs = append(logicalIDs, strconv.Itoa(index))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
devicePaths = append(devicePaths, "/dev/davinci"+id)
|
|
||||||
}
|
|
||||||
devicePaths = append(devicePaths, "/dev/davinci_manager", "/dev/devmm_svm", "/dev/hisi_hdc")
|
|
||||||
} else if config.Mode == "all" {
|
|
||||||
devicePaths = append(devicePaths, "/dev/dri")
|
|
||||||
} else {
|
|
||||||
devicePaths = append(devicePaths, ids...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imageType == "nvidia" {
|
|
||||||
delete(service, "gpus")
|
|
||||||
}
|
|
||||||
if imageType == "nvidia" && resolved["runtime"] == "nvidia" && !config.Enabled {
|
|
||||||
delete(service, "runtime")
|
|
||||||
}
|
|
||||||
deploy, _ := service["deploy"].(map[string]interface{})
|
|
||||||
resources, _ := deploy["resources"].(map[string]interface{})
|
|
||||||
reservations, _ := resources["reservations"].(map[string]interface{})
|
|
||||||
devices, _ := reservations["devices"].([]interface{})
|
|
||||||
resolvedDevices, _ := resolved["reservations"].([]interface{})
|
|
||||||
kept := []interface{}{}
|
|
||||||
var gpuDevice map[string]interface{}
|
|
||||||
for index, item := range devices {
|
|
||||||
device, _ := resolvedDevices[index].(map[string]interface{})
|
|
||||||
capabilities, _ := device["capabilities"].([]interface{})
|
|
||||||
driver, _ := device["driver"].(string)
|
|
||||||
isNvidiaGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
|
|
||||||
if imageType != "nvidia" || !isNvidiaGPU {
|
|
||||||
kept = append(kept, item)
|
|
||||||
} else if gpuDevice == nil {
|
|
||||||
gpuDevice, _ = item.(map[string]interface{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Enabled && imageType == "nvidia" {
|
|
||||||
device := gpuDevice
|
|
||||||
if device == nil {
|
|
||||||
device = map[string]interface{}{}
|
|
||||||
}
|
|
||||||
device["driver"] = "nvidia"
|
|
||||||
device["capabilities"] = config.Capabilities
|
|
||||||
delete(device, "count")
|
|
||||||
delete(device, "device_ids")
|
|
||||||
switch config.Mode {
|
|
||||||
case "all":
|
|
||||||
device["count"] = "all"
|
|
||||||
case "count":
|
|
||||||
device["count"] = config.Count
|
|
||||||
default:
|
|
||||||
ids := []string{}
|
|
||||||
for _, id := range config.DeviceIDs {
|
|
||||||
ids = append(ids, strings.TrimPrefix(id, "nvidia:"))
|
|
||||||
}
|
|
||||||
device["device_ids"] = ids
|
|
||||||
}
|
|
||||||
kept = append(kept, device)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(kept) > 0 {
|
|
||||||
if deploy == nil {
|
|
||||||
deploy = map[string]interface{}{}
|
|
||||||
service["deploy"] = deploy
|
|
||||||
}
|
|
||||||
if resources == nil {
|
|
||||||
resources = map[string]interface{}{}
|
|
||||||
deploy["resources"] = resources
|
|
||||||
}
|
|
||||||
if reservations == nil {
|
|
||||||
reservations = map[string]interface{}{}
|
|
||||||
resources["reservations"] = reservations
|
|
||||||
}
|
|
||||||
reservations["devices"] = kept
|
|
||||||
} else {
|
|
||||||
delete(reservations, "devices")
|
|
||||||
if len(reservations) == 0 {
|
|
||||||
delete(resources, "reservations")
|
|
||||||
}
|
|
||||||
if len(resources) == 0 {
|
|
||||||
delete(deploy, "resources")
|
|
||||||
}
|
|
||||||
if len(deploy) == 0 {
|
|
||||||
delete(service, "deploy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mappedPaths := []string{}
|
|
||||||
for _, key := range []string{"devices", "volumes"} {
|
|
||||||
mappings, _ := service[key].([]interface{})
|
|
||||||
resolvedMappings, _ := resolved[key].([]interface{})
|
|
||||||
kept := []interface{}{}
|
|
||||||
for index, item := range mappings {
|
|
||||||
source := modelGPUDeviceSource(resolvedMappings[index])
|
|
||||||
managed := false
|
|
||||||
switch imageType {
|
|
||||||
case "nvidia":
|
|
||||||
managed = strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=")
|
|
||||||
case "intel":
|
|
||||||
managed = source == "/dev/dri" || strings.HasPrefix(source, "/dev/dri/")
|
|
||||||
case "ascend":
|
|
||||||
managed = strings.HasPrefix(source, "/dev/davinci") || source == "/dev/devmm_svm" || source == "/dev/hisi_hdc"
|
|
||||||
}
|
|
||||||
if managed {
|
|
||||||
selected := slices.Contains(devicePaths, source)
|
|
||||||
if config.Enabled && config.Mode == "all" && imageType == "intel" {
|
|
||||||
selected = true
|
|
||||||
}
|
|
||||||
if selected {
|
|
||||||
kept = append(kept, item)
|
|
||||||
mappedPaths = append(mappedPaths, source)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, item)
|
|
||||||
}
|
|
||||||
if len(kept) == 0 {
|
|
||||||
delete(service, key)
|
|
||||||
} else {
|
|
||||||
service[key] = kept
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Enabled && imageType != "nvidia" {
|
|
||||||
devices, _ := service["devices"].([]interface{})
|
|
||||||
for _, path := range devicePaths {
|
|
||||||
if !slices.Contains(mappedPaths, path) {
|
|
||||||
devices = append(devices, path+":"+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(devices) > 0 {
|
|
||||||
service["devices"] = devices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gpuEnvironment := map[string]string{}
|
|
||||||
if config.Enabled && imageType == "ascend" && config.Mode != "all" {
|
|
||||||
gpuEnvironment["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
|
|
||||||
}
|
|
||||||
var environmentKeys []string
|
|
||||||
switch imageType {
|
|
||||||
case "nvidia":
|
|
||||||
environmentKeys = []string{"NVIDIA_VISIBLE_DEVICES", "NVIDIA_DRIVER_CAPABILITIES"}
|
|
||||||
case "ascend":
|
|
||||||
environmentKeys = []string{"ASCEND_RT_VISIBLE_DEVICES"}
|
|
||||||
if params != nil {
|
|
||||||
params["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch environment := service["environment"].(type) {
|
|
||||||
case map[string]interface{}:
|
|
||||||
for _, key := range environmentKeys {
|
|
||||||
delete(environment, key)
|
|
||||||
}
|
|
||||||
for key, value := range gpuEnvironment {
|
|
||||||
environment[key] = value
|
|
||||||
}
|
|
||||||
case []interface{}:
|
|
||||||
kept := []interface{}{}
|
|
||||||
for _, item := range environment {
|
|
||||||
value, ok := item.(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid Compose environment")
|
|
||||||
}
|
|
||||||
key, _, _ := strings.Cut(value, "=")
|
|
||||||
if !slices.Contains(environmentKeys, key) {
|
|
||||||
kept = append(kept, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for key, value := range gpuEnvironment {
|
|
||||||
kept = append(kept, key+"="+value)
|
|
||||||
}
|
|
||||||
service["environment"] = kept
|
|
||||||
case nil:
|
|
||||||
if len(gpuEnvironment) > 0 {
|
|
||||||
environment := map[string]interface{}{}
|
|
||||||
for key, value := range gpuEnvironment {
|
|
||||||
environment[key] = value
|
|
||||||
}
|
|
||||||
service["environment"] = environment
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid Compose environment")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadModelGPU(service map[string]interface{}, params map[string]interface{}, imageType string) *dto.ModelGPUConfig {
|
|
||||||
resolved, err := resolveModelGPUService(service, params, true)
|
|
||||||
if err != nil {
|
|
||||||
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
|
|
||||||
}
|
|
||||||
if imageType == "nvidia" {
|
|
||||||
config, err := readModelGPU(service, resolved)
|
|
||||||
if err != nil {
|
|
||||||
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
config := &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
|
|
||||||
privileged := resolved["privileged"] == true || resolved["privileged"] == "true"
|
|
||||||
all := false
|
|
||||||
for _, key := range []string{"devices", "volumes"} {
|
|
||||||
mappings, _ := resolved[key].([]interface{})
|
|
||||||
for _, item := range mappings {
|
|
||||||
source := modelGPUDeviceSource(item)
|
|
||||||
id := ""
|
|
||||||
if imageType == "intel" {
|
|
||||||
switch {
|
|
||||||
case source == "/dev/dri":
|
|
||||||
all = true
|
|
||||||
case re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(source):
|
|
||||||
id = imageType + ":" + source
|
|
||||||
case strings.HasPrefix(source, "/dev/dri/"):
|
|
||||||
config.Error = "unsupported Intel GPU mapping requires an explicit selection: " + source
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imageType == "ascend" && re.GetRegex(re.AscendGPUDevicePathPattern).MatchString(source) {
|
|
||||||
id = imageType + ":" + strings.TrimPrefix(source, "/dev/davinci")
|
|
||||||
}
|
|
||||||
if id != "" && !slices.Contains(config.DeviceIDs, id) {
|
|
||||||
config.DeviceIDs = append(config.DeviceIDs, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.Enabled = all || len(config.DeviceIDs) > 0
|
|
||||||
config.Mode = "devices"
|
|
||||||
if all {
|
|
||||||
config.Mode = "all"
|
|
||||||
config.DeviceIDs = []string{}
|
|
||||||
}
|
|
||||||
if imageType == "ascend" {
|
|
||||||
const key = "ASCEND_RT_VISIBLE_DEVICES"
|
|
||||||
visible := ""
|
|
||||||
switch environment := service["environment"].(type) {
|
|
||||||
case map[string]interface{}:
|
|
||||||
if value, exists := environment[key]; exists {
|
|
||||||
if value == nil {
|
|
||||||
visible = "${" + key + "}"
|
|
||||||
} else {
|
|
||||||
visible = fmt.Sprint(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []interface{}:
|
|
||||||
for _, item := range environment {
|
|
||||||
value, _ := item.(string)
|
|
||||||
if value == key {
|
|
||||||
visible = "${" + key + "}"
|
|
||||||
} else if strings.HasPrefix(value, key+"=") {
|
|
||||||
visible = strings.TrimPrefix(value, key+"=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visible, err := resolveModelGPUValue(visible, params)
|
|
||||||
if err != nil {
|
|
||||||
config.Error = err.Error()
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
visible = strings.TrimSpace(visible)
|
|
||||||
if visible == "all" || (visible == "" && config.Enabled) {
|
|
||||||
config.Enabled = true
|
|
||||||
config.Mode = "all"
|
|
||||||
config.DeviceIDs = []string{}
|
|
||||||
} else if visible != "" {
|
|
||||||
physicalIDs := []string{}
|
|
||||||
for _, id := range config.DeviceIDs {
|
|
||||||
physicalIDs = append(physicalIDs, strings.TrimPrefix(id, "ascend:"))
|
|
||||||
}
|
|
||||||
slices.SortFunc(physicalIDs, func(a, b string) int {
|
|
||||||
left, _ := strconv.Atoi(a)
|
|
||||||
right, _ := strconv.Atoi(b)
|
|
||||||
return left - right
|
|
||||||
})
|
|
||||||
if privileged {
|
|
||||||
physicalIDs = loadModelAscendDeviceIDs()
|
|
||||||
}
|
|
||||||
selected := []string{}
|
|
||||||
for _, id := range strings.Split(visible, ",") {
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if !re.GetRegex(re.AscendGPUDeviceIDPattern).MatchString(id) {
|
|
||||||
config.Error = "invalid Ascend GPU device: " + id
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
index, err := strconv.Atoi(id)
|
|
||||||
if err != nil || index < 0 || index >= len(physicalIDs) {
|
|
||||||
config.Error = "Ascend logical GPU is not available in the container: " + id
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
selected = append(selected, "ascend:"+physicalIDs[index])
|
|
||||||
}
|
|
||||||
config.Enabled = true
|
|
||||||
config.Mode = "devices"
|
|
||||||
config.DeviceIDs = selected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
func readModelGPU(service, resolved map[string]interface{}) (*dto.ModelGPUConfig, error) {
|
|
||||||
config := &dto.ModelGPUConfig{Mode: "all", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
|
|
||||||
devices, _ := resolved["reservations"].([]interface{})
|
|
||||||
if service["gpus"] != nil {
|
|
||||||
return nil, fmt.Errorf("custom GPU declarations require an explicit selection")
|
|
||||||
}
|
|
||||||
for _, item := range devices {
|
|
||||||
device, ok := item.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid GPU reservation")
|
|
||||||
}
|
|
||||||
driver, _ := device["driver"].(string)
|
|
||||||
capabilities, _ := device["capabilities"].([]interface{})
|
|
||||||
isGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
|
|
||||||
if !isGPU {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if config.Enabled {
|
|
||||||
return nil, fmt.Errorf("multiple GPU declarations require an explicit selection")
|
|
||||||
}
|
|
||||||
config.Enabled = true
|
|
||||||
ids, _ := device["device_ids"].([]interface{})
|
|
||||||
if len(ids) > 0 {
|
|
||||||
if device["count"] != nil {
|
|
||||||
return nil, fmt.Errorf("GPU count and device_ids cannot be combined")
|
|
||||||
}
|
|
||||||
config.Mode = "devices"
|
|
||||||
for _, id := range ids {
|
|
||||||
config.DeviceIDs = append(config.DeviceIDs, "nvidia:"+fmt.Sprint(id))
|
|
||||||
}
|
|
||||||
} else if count := device["count"]; count != nil && fmt.Sprint(count) != "all" && fmt.Sprint(count) != "-1" {
|
|
||||||
config.Mode = "count"
|
|
||||||
countValue, err := strconv.Atoi(fmt.Sprint(count))
|
|
||||||
config.Count = countValue
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid GPU count: %v", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, capability := range capabilities {
|
|
||||||
value, ok := capability.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unsupported GPU capabilities: %v", capabilities)
|
|
||||||
}
|
|
||||||
if !slices.Contains(config.Capabilities, value) {
|
|
||||||
config.Capabilities = append(config.Capabilities, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, key := range []string{"devices", "volumes"} {
|
|
||||||
mappings, _ := resolved[key].([]interface{})
|
|
||||||
for _, item := range mappings {
|
|
||||||
source := modelGPUDeviceSource(item)
|
|
||||||
if strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=") {
|
|
||||||
return nil, fmt.Errorf("direct NVIDIA device mappings require an explicit selection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !config.Enabled && resolved["runtime"] == "nvidia" {
|
|
||||||
return nil, fmt.Errorf("custom NVIDIA runtime configuration requires an explicit selection")
|
|
||||||
}
|
|
||||||
if err := validateModelGPU(config, "nvidia"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateModelGPU(config *dto.ModelGPUConfig, imageType string) error {
|
|
||||||
if config.Error != "" {
|
|
||||||
return fmt.Errorf("GPU configuration must be selected explicitly: %s", config.Error)
|
|
||||||
}
|
|
||||||
if !config.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
patterns := map[string]string{
|
|
||||||
"nvidia": re.NvidiaGPUDeviceIDPattern,
|
|
||||||
"intel": re.IntelGPUDevicePathPattern,
|
|
||||||
"ascend": re.AscendGPUDeviceIDPattern,
|
|
||||||
}
|
|
||||||
pattern, supported := patterns[imageType]
|
|
||||||
if !supported {
|
|
||||||
return fmt.Errorf("unsupported GPU type: %s", imageType)
|
|
||||||
}
|
|
||||||
switch config.Mode {
|
|
||||||
case "all":
|
|
||||||
if config.Count != 0 || len(config.DeviceIDs) != 0 {
|
|
||||||
return fmt.Errorf("all GPUs cannot be combined with count or device IDs")
|
|
||||||
}
|
|
||||||
case "count":
|
|
||||||
if imageType != "nvidia" || config.Count < 1 || len(config.DeviceIDs) != 0 {
|
|
||||||
return fmt.Errorf("GPU count requires a positive number and runtime device allocation")
|
|
||||||
}
|
|
||||||
case "devices", "":
|
|
||||||
if config.Count != 0 || len(config.DeviceIDs) == 0 {
|
|
||||||
return fmt.Errorf("select at least one GPU device")
|
|
||||||
}
|
|
||||||
for _, deviceID := range config.DeviceIDs {
|
|
||||||
id := strings.TrimPrefix(deviceID, imageType+":")
|
|
||||||
if !re.GetRegex(pattern).MatchString(id) {
|
|
||||||
return fmt.Errorf("invalid %s GPU device: %s", imageType, deviceID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported GPU selection mode: %s", config.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if imageType != "nvidia" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !slices.Contains(config.Capabilities, "gpu") {
|
|
||||||
return fmt.Errorf("NVIDIA capabilities must include gpu")
|
|
||||||
}
|
|
||||||
for _, capability := range config.Capabilities {
|
|
||||||
if capability != "gpu" && capability != "utility" && capability != "compute" {
|
|
||||||
return fmt.Errorf("unsupported NVIDIA capability: %s", capability)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveModelGPUService(service map[string]interface{}, params map[string]interface{}, resolveSelection bool) (map[string]interface{}, error) {
|
|
||||||
fields := map[string]interface{}{}
|
|
||||||
fields["runtime"] = service["runtime"]
|
|
||||||
deploy, _ := service["deploy"].(map[string]interface{})
|
|
||||||
resources, _ := deploy["resources"].(map[string]interface{})
|
|
||||||
reservations, _ := resources["reservations"].(map[string]interface{})
|
|
||||||
fields["reservations"] = reservations["devices"]
|
|
||||||
if devices, ok := reservations["devices"].([]interface{}); ok && !resolveSelection {
|
|
||||||
declarations := make([]interface{}, 0, len(devices))
|
|
||||||
for _, item := range devices {
|
|
||||||
device, _ := item.(map[string]interface{})
|
|
||||||
declarations = append(declarations, map[string]interface{}{"driver": device["driver"], "capabilities": device["capabilities"]})
|
|
||||||
}
|
|
||||||
fields["reservations"] = declarations
|
|
||||||
}
|
|
||||||
resolved, err := interpolation.Interpolate(fields, interpolation.Options{
|
|
||||||
Substitute: func(value string, _ template.Mapping) (string, error) { return resolveModelGPUValue(value, params) },
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, key := range []string{"devices", "volumes"} {
|
|
||||||
mappings, _ := service[key].([]interface{})
|
|
||||||
sources := make([]interface{}, 0, len(mappings))
|
|
||||||
for _, item := range mappings {
|
|
||||||
source, err := template.SubstituteWithOptions(modelGPUDeviceSource(item), func(name string) (string, bool) {
|
|
||||||
value, ok := params[name]
|
|
||||||
if !ok || value == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return fmt.Sprint(value), true
|
|
||||||
}, template.WithoutLogging)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, short := item.(string); short {
|
|
||||||
source = modelGPUDeviceSource(source)
|
|
||||||
}
|
|
||||||
sources = append(sources, map[string]interface{}{"source": source})
|
|
||||||
}
|
|
||||||
resolved[key] = sources
|
|
||||||
}
|
|
||||||
resolved["privileged"] = service["privileged"]
|
|
||||||
if value, ok := service["privileged"].(string); ok {
|
|
||||||
if value, err := resolveModelGPUValue(value, params); err == nil {
|
|
||||||
resolved["privileged"] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelGPUDeviceSource(item interface{}) string {
|
|
||||||
switch mapping := item.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.HasPrefix(mapping, "nvidia.com/gpu=") {
|
|
||||||
return mapping
|
|
||||||
}
|
|
||||||
depth := 0
|
|
||||||
for index, ch := range mapping {
|
|
||||||
switch ch {
|
|
||||||
case '{':
|
|
||||||
depth++
|
|
||||||
case '}':
|
|
||||||
depth--
|
|
||||||
case ':':
|
|
||||||
if depth == 0 {
|
|
||||||
return mapping[:index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapping
|
|
||||||
case map[string]interface{}:
|
|
||||||
source, _ := mapping["source"].(string)
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveModelGPUValue(value string, params map[string]interface{}) (string, error) {
|
|
||||||
return template.SubstituteWithOptions(value, func(name string) (string, bool) {
|
|
||||||
value, ok := params[name]
|
|
||||||
if !ok || value == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return fmt.Sprint(value), true
|
|
||||||
}, template.WithoutLogging, template.WithReplacementFunction(func(value string, mapping template.Mapping, config *template.Config) (string, error) {
|
|
||||||
resolved, applied, err := template.DefaultReplacementAppliedFunc(value, mapping, config)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !applied {
|
|
||||||
return "", fmt.Errorf("unresolved GPU variable: %s", value)
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadModelAscendDeviceIDs() []string {
|
|
||||||
ids := []string{}
|
|
||||||
entries, err := os.ReadDir("/dev")
|
|
||||||
if err != nil {
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !re.GetRegex(re.AscendGPUDeviceNamePattern).MatchString(entry.Name()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := os.Stat(filepath.Join("/dev", entry.Name()))
|
|
||||||
if err == nil && info.Mode()&os.ModeCharDevice != 0 {
|
|
||||||
ids = append(ids, strings.TrimPrefix(entry.Name(), "davinci"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.SortFunc(ids, func(a, b string) int {
|
|
||||||
left, _ := strconv.Atoi(a)
|
|
||||||
right, _ := strconv.Atoi(b)
|
|
||||||
return left - right
|
|
||||||
})
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/psutil"
|
"github.com/1Panel-dev/1Panel/agent/utils/psutil"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
@@ -207,14 +205,7 @@ func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
_, productName, _ := strings.Cut(device.ProductName, " - ")
|
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{DeviceID: device.DeviceID, ProductName: device.ProductName, Type: device.DeviceType, Legacy: device.DeviceID == ""})
|
||||||
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{
|
|
||||||
DeviceID: device.DeviceID,
|
|
||||||
ProductName: device.ProductName,
|
|
||||||
Type: device.DeviceType,
|
|
||||||
Legacy: device.DeviceID == "",
|
|
||||||
SharedMemory: strings.HasPrefix(device.DeviceID, "nvidia:") && gpu.UsesSystemMemory(productName),
|
|
||||||
})
|
|
||||||
data.Options = append(data.Options, device.ProductName)
|
data.Options = append(data.Options, device.ProductName)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
@@ -809,10 +800,9 @@ func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
|
|||||||
})
|
})
|
||||||
for _, item := range snapshot.Devices {
|
for _, item := range snapshot.Devices {
|
||||||
chartHide := dto.GPUChartHide{
|
chartHide := dto.GPUChartHide{
|
||||||
SharedMemory: item.GPU != nil && item.GPU.SharedMemory,
|
DeviceID: item.ID,
|
||||||
DeviceID: item.ID,
|
ProductName: item.Label,
|
||||||
ProductName: item.Label,
|
Type: string(item.Kind),
|
||||||
Type: string(item.Kind),
|
|
||||||
}
|
}
|
||||||
data.ChartHide = append(data.ChartHide, chartHide)
|
data.ChartHide = append(data.ChartHide, chartHide)
|
||||||
data.Options = append(data.Options, chartHide.ProductName)
|
data.Options = append(data.Options, chartHide.ProductName)
|
||||||
|
|||||||
@@ -78,17 +78,9 @@ func getNginxParamsByKeys(scope string, keys []string, website *model.Website) (
|
|||||||
var res []response.NginxParam
|
var res []response.NginxParam
|
||||||
var block components.IBlock
|
var block components.IBlock
|
||||||
if scope == constant.NginxScopeHttp {
|
if scope == constant.NginxScopeHttp {
|
||||||
httpBlock := nginxFull.RootConfig.Config.FindHttp()
|
block = nginxFull.RootConfig.Config.FindHttp()
|
||||||
if httpBlock == nil {
|
|
||||||
return nil, fmt.Errorf("nginx HTTP configuration block is missing")
|
|
||||||
}
|
|
||||||
block = httpBlock
|
|
||||||
} else {
|
} else {
|
||||||
servers := nginxFull.SiteConfig.Config.FindServers()
|
block = nginxFull.SiteConfig.Config.FindServers()[0]
|
||||||
if len(servers) == 0 {
|
|
||||||
return nil, fmt.Errorf("website nginx server block is missing")
|
|
||||||
}
|
|
||||||
block = servers[0]
|
|
||||||
}
|
}
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
dirs := block.FindDirectives(key)
|
dirs := block.FindDirectives(key)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *DashboardService) ListQuickDirectories() ([]model.QuickDirectory, error) {
|
|
||||||
return launcherRepo.ListQuickDirectories()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quickDirectoryValues(req dto.QuickDirectoryCreate) (model.QuickDirectory, error) {
|
|
||||||
item := model.QuickDirectory{Path: req.Path, Alias: strings.TrimSpace(req.Alias)}
|
|
||||||
if strings.TrimSpace(item.Path) == "" {
|
|
||||||
return item, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
|
|
||||||
}
|
|
||||||
return item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DashboardService) CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error) {
|
|
||||||
item, err := quickDirectoryValues(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := launcherRepo.CreateQuickDirectory(&item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DashboardService) UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error) {
|
|
||||||
item, err := quickDirectoryValues(req.QuickDirectoryCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
item.ID = req.ID
|
|
||||||
item.Legacy = req.Legacy
|
|
||||||
if err := launcherRepo.UpdateQuickDirectory(&item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DashboardService) DeleteQuickDirectory(req dto.QuickDirectoryDelete) error {
|
|
||||||
return launcherRepo.DeleteQuickDirectory(req.ID, req.Legacy)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -47,7 +46,7 @@ type RuntimeService struct {
|
|||||||
type IRuntimeService interface {
|
type IRuntimeService interface {
|
||||||
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
|
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
|
||||||
Create(create request.RuntimeCreate) (*model.Runtime, error)
|
Create(create request.RuntimeCreate) (*model.Runtime, error)
|
||||||
Delete(delete request.RuntimeDelete) (string, error)
|
Delete(delete request.RuntimeDelete) error
|
||||||
Update(req request.RuntimeUpdate) error
|
Update(req request.RuntimeUpdate) error
|
||||||
Get(id uint) (res *response.RuntimeDTO, err error)
|
Get(id uint) (res *response.RuntimeDTO, err error)
|
||||||
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
|
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
|
||||||
@@ -377,16 +376,16 @@ func (r *RuntimeService) DeleteCheck(runTimeId uint) ([]dto.AppResource, error)
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) (string, error) {
|
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) error {
|
||||||
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
|
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
|
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
|
||||||
if website.ID > 0 {
|
if website.ID > 0 {
|
||||||
return runtime.Name, buserr.New("ErrDelWithWebsite")
|
return buserr.New("ErrDelWithWebsite")
|
||||||
}
|
}
|
||||||
return runtime.Name, startRuntimeDeleteTask(runtimeDelete, runtime)
|
return startRuntimeDeleteTask(runtimeDelete, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRuntimeDeleteTask(runtimeDelete request.RuntimeDelete, runtime *model.Runtime) error {
|
func startRuntimeDeleteTask(runtimeDelete request.RuntimeDelete, runtime *model.Runtime) error {
|
||||||
@@ -585,21 +584,6 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
|
|||||||
|
|
||||||
for _, form := range appForm.FormFields {
|
for _, form := range appForm.FormFields {
|
||||||
if v, ok := envs[form.EnvKey]; ok {
|
if v, ok := envs[form.EnvKey]; ok {
|
||||||
if form.EnvKey == "PHP_VERSION" {
|
|
||||||
// Refresh only version choices; keep the saved form if synchronized metadata is unavailable.
|
|
||||||
var latestForm dto.AppForm
|
|
||||||
if json.Unmarshal([]byte(appDetail.Params), &latestForm) == nil {
|
|
||||||
for _, latest := range latestForm.FormFields {
|
|
||||||
if latest.EnvKey == form.EnvKey && len(latest.Values) > 0 {
|
|
||||||
form.Values = latest.Values
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v != "" && !slices.ContainsFunc(form.Values, func(value dto.AppFormValue) bool { return value.Value == v }) {
|
|
||||||
form.Values = append(form.Values, dto.AppFormValue{Label: v, Value: v})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appParam := response.AppParam{
|
appParam := response.AppParam{
|
||||||
Edit: false,
|
Edit: false,
|
||||||
Key: form.EnvKey,
|
Key: form.EnvKey,
|
||||||
@@ -647,7 +631,7 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
|
|||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
|
||||||
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
|
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -656,7 +640,6 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
runtime.Version = req.Version
|
runtime.Version = req.Version
|
||||||
return runtimeRepo.Save(runtime)
|
return runtimeRepo.Save(runtime)
|
||||||
}
|
}
|
||||||
oldRuntime := *runtime
|
|
||||||
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
|
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -701,20 +684,6 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
if err = downloadApp(app, appDetail, nil, nil); err != nil {
|
if err = downloadApp(app, appDetail, nil, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var snapshot *runtimeUpdateSnapshot
|
|
||||||
if runtime.Type != constant.RuntimePHP {
|
|
||||||
snapshot, err = snapshotRuntimeUpdate(&oldRuntime)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
if restoreErr := snapshot.restoreFiles(); restoreErr != nil {
|
|
||||||
err = fmt.Errorf("%w; restore runtime configuration: %v", err, restoreErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if versionChanged {
|
if versionChanged {
|
||||||
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
|
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -788,13 +757,7 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
if err = runtimeRepo.Save(runtime); err != nil {
|
if err = runtimeRepo.Save(runtime); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go reCreateRuntime(runtime)
|
||||||
if err := prepareRuntimeUpdate(runtime, snapshot); err != nil {
|
|
||||||
global.LOG.Errorf("prepare runtime [%s] update failed: %v", runtime.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reCreateRuntime(runtime)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetPHPRuntimeRefreshesVersionOptionsWithoutChangingConfig(t *testing.T) {
|
|
||||||
for _, resource := range []string{constant.AppResourceRemote, constant.AppResourceLocal, constant.AppResourceCustom} {
|
|
||||||
t.Run(resource, func(t *testing.T) {
|
|
||||||
db := appCacheTestDB(t)
|
|
||||||
if err := db.AutoMigrate(&model.Runtime{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: resource}
|
|
||||||
if err := db.Create(&app).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
oldVersion := dto.AppFormValue{Label: "8.1.34", Value: "8.1.34"}
|
|
||||||
newVersion := dto.AppFormValue{Label: "8.1.35", Value: "8.1.35"}
|
|
||||||
savedForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
|
||||||
{EnvKey: "PHP_VERSION", Type: "select", Default: oldVersion.Value, Values: []dto.AppFormValue{oldVersion}},
|
|
||||||
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Values: []dto.AppFormValue{{Label: "redis", Value: "redis"}}},
|
|
||||||
{EnvKey: "PANEL_APP_PORT_HTTP", Type: "number"},
|
|
||||||
{EnvKey: "CONTAINER_PACKAGE_URL", Type: "select", Values: []dto.AppFormValue{{Label: "custom mirror", Value: "https://mirror.example"}}},
|
|
||||||
}}
|
|
||||||
encode := func(form dto.AppForm) string {
|
|
||||||
t.Helper()
|
|
||||||
data, err := json.Marshal(form)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
detail := model.AppDetail{AppId: app.ID, Version: "8.1", Params: encode(savedForm), Update: true}
|
|
||||||
if err := db.Create(&detail).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
otherMajor := model.AppDetail{AppId: app.ID, Version: "8.2", Params: `{"formFields":[{"envKey":"PHP_VERSION","values":[{"label":"8.2.31","value":"8.2.31"}]}]}`}
|
|
||||||
if err := db.Create(&otherMajor).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runtime := model.Runtime{
|
|
||||||
Name: "existing-php", Type: constant.RuntimePHP, Resource: constant.ResourceAppstore,
|
|
||||||
Version: detail.Version, AppDetailID: detail.ID, Image: "custom-php:existing", Params: detail.Params,
|
|
||||||
Env: "PHP_VERSION=8.1.34\nPHP_EXTENSIONS=redis,custom-extension\nPANEL_APP_PORT_HTTP=9070\nCONTAINER_PACKAGE_URL=https://mirror.example\nCONTAINER_NAME=my-php\nCUSTOM_SETTING=keep-me\n",
|
|
||||||
DockerCompose: "existing compose", Remark: "keep remark",
|
|
||||||
}
|
|
||||||
if err := db.Create(&runtime).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service := NewRuntimeService()
|
|
||||||
before, err := service.Get(runtime.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
latestForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
|
||||||
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion, oldVersion}},
|
|
||||||
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Default: "opcache"},
|
|
||||||
{EnvKey: "PANEL_APP_PORT_HTTP", Default: 9000},
|
|
||||||
}}
|
|
||||||
removedForm := dto.AppForm{FormFields: []dto.AppFormFields{
|
|
||||||
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion}},
|
|
||||||
}}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
params string
|
|
||||||
values []dto.AppFormValue
|
|
||||||
}{
|
|
||||||
{"new patch on reopen", encode(latestForm), []dto.AppFormValue{newVersion, oldVersion}},
|
|
||||||
{"selected version removed", encode(removedForm), []dto.AppFormValue{newVersion, oldVersion}},
|
|
||||||
{"invalid metadata", "{invalid", []dto.AppFormValue{oldVersion}},
|
|
||||||
{"missing metadata", "", []dto.AppFormValue{oldVersion}},
|
|
||||||
{"missing version field", `{"formFields":[{"envKey":"PHP_EXTENSIONS"}]}`, []dto.AppFormValue{oldVersion}},
|
|
||||||
{"empty version list", `{"formFields":[{"envKey":"PHP_VERSION","values":[]}]}`, []dto.AppFormValue{oldVersion}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if err := db.Model(&detail).UpdateColumn("params", tc.params).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := service.Get(runtime.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := *before
|
|
||||||
want.AppParams = slices.Clone(before.AppParams)
|
|
||||||
want.AppParams[0].Values = tc.values
|
|
||||||
if !reflect.DeepEqual(*got, want) {
|
|
||||||
t.Fatalf("edit response changed configuration or returned stale options:\ngot: %+v\nwant: %+v", *got, want)
|
|
||||||
}
|
|
||||||
var stored model.Runtime
|
|
||||||
if err := db.First(&stored, runtime.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored.Params != runtime.Params || stored.Env != runtime.Env || stored.Image != runtime.Image || stored.DockerCompose != runtime.DockerCompose || stored.AppDetailID != runtime.AppDetailID || stored.Version != runtime.Version {
|
|
||||||
t.Fatal("opening edit mutated the saved runtime")
|
|
||||||
}
|
|
||||||
var storedDetail model.AppDetail
|
|
||||||
if err := db.First(&storedDetail, detail.ID).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !storedDetail.Update {
|
|
||||||
t.Fatal("opening edit cleared the pending template update")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
@@ -15,89 +12,6 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keep the on-disk configuration as well as the model: files may contain edits
|
|
||||||
// that have not been reflected in the database.
|
|
||||||
type runtimeUpdateSnapshot struct {
|
|
||||||
runtime model.Runtime
|
|
||||||
files []runtimeUpdateFile
|
|
||||||
}
|
|
||||||
|
|
||||||
type runtimeUpdateFile struct {
|
|
||||||
path string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
exists bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshotRuntimeUpdate(runtime *model.Runtime) (*runtimeUpdateSnapshot, error) {
|
|
||||||
snapshot := &runtimeUpdateSnapshot{runtime: *runtime}
|
|
||||||
for _, name := range []string{"docker-compose.yml", ".env", "run.sh"} {
|
|
||||||
file := runtimeUpdateFile{path: filepath.Join(runtime.GetPath(), name)}
|
|
||||||
info, err := os.Stat(file.path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
snapshot.files = append(snapshot.files, file)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file.content, err = os.ReadFile(file.path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file.exists, file.mode = true, info.Mode().Perm()
|
|
||||||
snapshot.files = append(snapshot.files, file)
|
|
||||||
}
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *runtimeUpdateSnapshot) restoreFiles() error {
|
|
||||||
var result error
|
|
||||||
for _, file := range s.files {
|
|
||||||
var err error
|
|
||||||
if file.exists {
|
|
||||||
err = os.WriteFile(file.path, file.content, file.mode)
|
|
||||||
if err == nil {
|
|
||||||
err = os.Chmod(file.path, file.mode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = os.Remove(file.path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
result = errors.Join(result, fmt.Errorf("restore %s: %w", file.path, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// This must finish before reCreateRuntime stops the existing container. A
|
|
||||||
// failed preparation restores the previous version without touching Docker.
|
|
||||||
func prepareRuntimeUpdate(runtime *model.Runtime, snapshot *runtimeUpdateSnapshot) error {
|
|
||||||
if err := pullRuntimeComposeImages(runtime.GetComposePath(), false, nil); err != nil {
|
|
||||||
result := fmt.Errorf("prepare runtime images: %w", err)
|
|
||||||
restoreErr := snapshot.restoreFiles()
|
|
||||||
*runtime = snapshot.runtime
|
|
||||||
if restoreErr != nil {
|
|
||||||
result = errors.Join(result, restoreErr)
|
|
||||||
runtime.Status = constant.StatusError
|
|
||||||
}
|
|
||||||
runtime.Message = result.Error()
|
|
||||||
if saveErr := runtimeRepo.Save(runtime); saveErr != nil {
|
|
||||||
result = errors.Join(result, fmt.Errorf("restore runtime record: %w", saveErr))
|
|
||||||
}
|
|
||||||
logFile, logErr := os.OpenFile(runtime.GetLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, constant.FilePerm)
|
|
||||||
if logErr == nil {
|
|
||||||
_, logErr = fmt.Fprintln(logFile, result.Error())
|
|
||||||
logErr = errors.Join(logErr, logFile.Close())
|
|
||||||
}
|
|
||||||
return errors.Join(result, logErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
|
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
|
||||||
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
|
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TaskLogService struct {
|
type TaskLogService struct{}
|
||||||
grantedScopes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ITaskLogService interface {
|
type ITaskLogService interface {
|
||||||
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
|
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
|
||||||
@@ -22,18 +19,13 @@ type ITaskLogService interface {
|
|||||||
CountExecutingTask() (int64, error)
|
CountExecutingTask() (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewITaskService(grantedScopes ...string) ITaskLogService {
|
func NewITaskService() ITaskLogService {
|
||||||
return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
|
return &TaskLogService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
|
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
opts := []repo.DBOption{
|
opts := []repo.DBOption{
|
||||||
repo.WithOrderDesc("created_at"),
|
repo.WithOrderDesc("created_at"),
|
||||||
taskRepo.WithoutScopes(excluded...),
|
|
||||||
}
|
}
|
||||||
if req.TaskID != "" {
|
if req.TaskID != "" {
|
||||||
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
||||||
@@ -61,11 +53,7 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
|
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
opts := []repo.DBOption{}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
|
|
||||||
if req.TaskID != "" {
|
if req.TaskID != "" {
|
||||||
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
||||||
} else {
|
} else {
|
||||||
@@ -124,17 +112,9 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) SyncForRestart() error {
|
func (u *TaskLogService) SyncForRestart() error {
|
||||||
excluded, err := taskscope.RecoveryExclusions()
|
return taskRepo.UpdateRunningTaskToFailed()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) CountExecutingTask() (int64, error) {
|
func (u *TaskLogService) CountExecutingTask() (int64, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
return taskRepo.CountExecutingTask()
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user