Compare commits

...
Author SHA1 Message Date
ssongliu ce429dbf67 fix(ai): refine model service forms and Ollama drawers 2026-11-03 00:03:35 +08:00
蘭 e29679768b feat: add MCP error metadata handling and improve source policy resolution (#14270)
* feat: add MCP error metadata handling and improve source policy resolution

* fix: adjust height-diff for improved table layout
2026-11-02 22:17:12 +08:00
CityFun 8e1b761f4c fix(app): allow upgrades from removed versions (#14280) 2026-11-02 22:17:03 +08:00
蘭 963c1463a8 fix: update MCP access permissions and improve titles in routing (#14281) 2026-11-02 22:16:53 +08:00
ssongliu 2401bb16a2 feat(ai): connect local model accounts and add Ollama management page (#14282)
* feat(ai): connect local model accounts and add Ollama management page

* fix(ai): register Ollama management frontend path
2026-11-02 22:16:43 +08:00
ssongliu c06c4e338b feat(vm): enable community virtual machines and migrate xpack data (#14279)
* feat(vm): enable community virtual machines and migrate xpack data

* fix(vm): stop ISO download polling on failure or cancellation

* fix(vm): respect deleteFile when deleting failed ISO imports
2026-11-02 18:22:58 +08:00
ssongliu f8c61993c5 feat(ai): open source model marketplace and migrate historical data (#14277)
* feat(ai): open source model marketplace and migrate historical data

* fix(ai): wait for canceled model downloads before retrying
2026-11-02 17:46:26 +08:00
ssongliu b31e683f1b fix: correct local inference engine operation task logs (#14274) 2026-11-02 17:27:43 +08:00
Yuki Guo 50f0a37bee feat: add remote Docker Hub image search (#14271) 2026-11-02 11:35:26 +08:00
ssongliu 51cf40a648 feat(ai): show local inference engine ports (#14273) 2026-11-02 11:21:17 +08:00
Yuki Guo 7ec31cd5e9 Merge pull request #14246 from 1Panel-dev/pr@dev-v2@feat_image_check
feat(container): add image update checks with scheduling and task logs
2026-11-02 10:48:03 +08:00
Yuki Guo 50a45b72f3 Merge branch 'dev-v2' into pr@dev-v2@feat_image_check 2026-11-02 09:44:27 +08:00
ssongliu 13362f84a6 fix(ai): synchronize inference engine task states and unify management (#14269) 2026-11-01 23:36:15 +08:00
ssongliu 046dc94d05 fix(script): bypass stale resource caches during library sync (#14268) 2026-11-01 22:32:58 +08:00
ssongliu 2059119d89 fix(dashboard): restore monitor layout and compact single GPU status (#14267) 2026-11-01 22:32:06 +08:00
ssongliu 7f4c503982 fix(ai): improve GPU capability hints and separate engine logs (#14263) 2026-11-01 01:06:37 +08:00
ssongliu 93ebc6a8b9 feat(ai): streamline Ollama model pool management (#14259) 2026-10-30 23:50:11 +08:00
王贺andzhengkunwang223 9a49f38ffd fix(network): disable MPTCP by default (#14258)
Set multipathtcp=0 in the core and agent module defaults so Linux listeners use standard TCP without requiring service environment changes.

Co-authored-by: zhengkunwang223 <31820853+zhengkunwang223@users.noreply.github.com>
2026-10-30 23:31:05 +08:00
ssongliu 7156addf79 feat(ai): refine model pools, navigation and GPU deployment configuration (#14257) 2026-10-30 23:00:23 +08:00
ssongliu c6329a62db feat(dashboard): refine monitoring, quick access and installation messages (#14256) 2026-10-30 22:46:20 +08:00
CityFunandssongliu 832d367428 perf(appstore): reduce resource requests during synchronization (#14248)
Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-30 18:14:41 +08:00
Yuki Guo e6c50b89ba fix: ca website upload url (#14244) 2026-10-30 18:12:25 +08:00
Yuki Guo f2aa51066d refactor(frontend): unify dialogs and improve deletion flows (#14243) 2026-10-30 18:12:07 +08:00
ssongliu 3f594bd7a2 feat: compact dashboard GPU layout for four devices (#14255) 2026-10-30 18:10:30 +08:00
ssongliu 02031e4d7c feat: support GB10 shared memory and refine AI model UI (#14254) 2026-10-30 18:09:51 +08:00
guoyuqi 9644d5429f refactor: reuse base settings for image update proxy checks 2026-10-30 17:42:24 +08:00
ssongliu 2f7bab67b3 feat(ai): add unified GPU configuration and collapsible deployment cards (#14253) 2026-10-30 17:03:21 +08:00
ssongliu 2d473042fc style(login): update enterprise login background (#14252) 2026-10-30 16:50:34 +08:00
ssongliu 8885463a75 feat(dashboard): add system and GPU status views (#14251) 2026-10-30 15:18:44 +08:00
ssongliu 01feaa6059 fix(ai): allow stopping and deleting restarting model containers (#14250) 2026-10-30 12:52:32 +08:00
ssongliu ecb264f087 fix(ai): clean up local model accounts during uninstall (#14249)
* fix(ai): clean up local model accounts during uninstall

* refactor(ai): restore best-effort local model account cleanup
2026-10-30 12:10:59 +08:00
蘭 8bae2f6779 feat: add delete and reveal endpoints, support deleted_at field (#14235) 2026-10-30 10:11:35 +08:00
guoyuqi 8b6328fd04 feat(container): add image update checks with scheduling and task logs 2026-10-29 19:02:44 +08:00
guoyuqi e9c2162921 feat(container): add image update checks with scheduling and task logs 2026-10-29 18:25:32 +08:00
ssongliu 87ab399bcc feat(ai): add download source icons and localize downloads labels (#14240) 2026-10-29 11:23:09 +08:00
Yuki Guo ecedaf8c21 feat(container): support batch upgrades with bounded concurrency and recovery (#14231) 2026-10-28 18:45:13 +08:00
ssongliu d7c42e3e8b fix(ai): unify model deletion prompts and register template route (#14233) 2026-10-28 18:20:06 +08:00
蘭 d32cbb40f6 feat(mcp): add Panel MCP task configuration and website binding structures (#14229)
* feat(mcp): add Panel MCP task configuration and website binding structures

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
CityFun 1798f9768e fix(ai): preserve legacy Feishu and safely migrate OpenClaw 2026.10.7 upgrades (#14214)
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades

* fix(ai): override inherited Feishu allowlists when cleared
2026-10-28 11:24:03 +08:00
Yuki Guo eb4beb50c4 fix: docker image pull and stop (#14202) 2026-10-27 23:07:21 +08:00
ssongliu 3a7cd211af fix: unify database credential validation (#14216) 2026-10-27 23:06:13 +08:00
ssongliu bd0bc0079e refactor(ai): remove obsolete tokenizer directory patterns (#14225) 2026-10-27 23:05:54 +08:00
ssongliu 93ed58d784 fix(terminal): improve dock button border in dark mode (#14224) 2026-10-27 17:22:51 +08:00
ssongliu 8d320ef2ca feat(node): add translations for existing node packages (#14222) 2026-10-27 17:12:14 +08:00
ssongliu b3c1e0df42 refactor(frontend): use DialogPro for task logs (#14212) 2026-10-27 14:34:20 +08:00
ssongliu 5346d5f58a fix(ai): reconcile Ollama models and improve engine management (#14211) 2026-10-27 11:06:32 +08:00
Raphael Fakhri ac09426f10 fix(app): keep signed numeric env values unchanged (#14180) 2026-10-27 09:58:53 +08:00
CityFun 19707a68dd fix(agent): support new Hermes Weixin module path (#14197) 2026-10-26 23:34:56 +08:00
ssongliu 7c7fd492c4 feat(ai): support benchmark report routes and parameter translations (#14210) 2026-10-26 23:33:17 +08:00
ssongliu fbf66766c2 fix: align AI menu order and labels (#14192) 2026-10-26 14:03:40 +08:00
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

* feat(i18n): implement MCP document localization and remove legacy code
2026-10-26 13:59:49 +08:00
CityFun 93ca724985 fix(appstore): refresh cached app resources and PHP runtime version options (#14185) 2026-10-26 11:29:29 +08:00
ssongliu 9481a7535d fix: align AI menu order and labels (#14183) 2026-10-26 11:29:20 +08:00
ssongliu 14be5b7870 feat(ai): translate benchmark comparison metrics (#14182) 2026-10-25 14:05:09 +08:00
ssongliu c4b76a8e89 feat(ai): unify model services and inference engine management (#14175) 2026-10-23 18:39:22 +08:00
Woolsenandssongliu 9295f4ef8a fix: correct 1pctl password complexity validation (#14169)
* fix: correct 1pctl password complexity validation

`isValidPassword("newPassword")` passed a literal string and the condition was inverted, so the complexity check never ran. Extract the validation into a testable helper and cover both the enabled and disabled paths.

Refs #12640

* Delete core/cmd/server/cmd/update_test.go

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-23 18:26:25 +08:00
Yuki Guo c925184bf6 fix: container list (#14156)
* fix: container list

* fix: container list
2026-10-23 18:19:52 +08:00
CityFun 87828b03ae fix(website): sync PHP runtime changes to subsites (#14166)
Update subsite upstreams and runtime references together with the parent site. Preserve stopped sites and custom PHP settings, and restore configurations and database records when applying the change fails.
2026-10-23 18:19:44 +08:00
Yuki Guo e7b4146e1b fix: operation log text (#14173) 2026-10-23 18:19:30 +08:00
Yuki Guo ec998552b9 feat: ca website upload (#14148) 2026-10-23 18:12:57 +08:00
Woolsen 1af237944d fix(file): reject rename when the target already exists (#13946) (#14168) 2026-10-23 14:05:48 +08:00
CityFun f9f9169a4d fix(build): upgrade Go to 1.27.3 (#14161) 2026-10-22 15:41:34 +08:00
zhengkunwang223 1e01761ba3 fix(appstore): refresh cached resources and PHP versions
Version official app package and compose downloads using the synchronized store revision. Refresh outdated local resource caches and use synchronized database parameters for official PHP version options.
2026-10-22 11:53:36 +08:00
CityFun 15283fde34 feat: update deepseek default model (#14146) 2026-10-21 18:37:22 +08:00
Octopusandocto-patch 49f8e7f040 feat(ai-agents): refresh MiniMax Flash models and defaults (#14136)
Co-authored-by: octo-patch <octo-patch@users.noreply.github.com>
2026-10-21 18:23:53 +08:00
Yuki Guo 98b27af6a0 fix: container list search (#14134) 2026-10-21 18:22:02 +08:00
ssongliu a393224729 feat(ai): support benchmark node options and report integration (#14145) 2026-10-21 18:07:39 +08:00
Woolsen b851c84290 fix(container): reset sort to default when cleared (#14140)
The container list opens with name ascending but shows no sort indicator. Clearing the name sort kept the previous descending order, so two no-indicator states represented different orders. Reset the request to the default name ascending when the sort is cleared.
2026-10-21 18:01:18 +08:00
Woolsen 372855157d feat(container): support sorting by creation time (#14141)
Add a creation time column to the container list. The column uses the backend createdAt order field and displays the existing createTime value. It follows the standard three-state sort cycle, starting with newest first.
2026-10-21 18:01:07 +08:00
CityFunandssongliu 577d771495 feat(agent): add DeepSeek Harness and OctoP support (#14138)
* feat(agent): add DeepSeek Harness and OctoP support

* fix: restore default core edition configuration

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-21 17:25:38 +08:00
Yuki Guo ca3e6511e5 feat: ssl push multiple directories (#14137) 2026-10-21 17:23:56 +08:00
Yuki Guo 8af14eae25 feat: add container catalog batch upload (#14133) 2026-10-21 17:09:01 +08:00
ssongliu 175fbaf3f2 fix: correct terminal node routing for containers (#14142) 2026-10-21 16:40:42 +08:00
Yuki Guo 13199efd62 feat: add overview more quick menu (#14132) 2026-10-21 16:21:07 +08:00
594 changed files with 72423 additions and 6281 deletions
+2
View File
@@ -79,3 +79,5 @@ opencode.json
superpowers
.worktrees/
.codex/
/agent/utils/vm/helper/bin/1panel-kvm*
+5 -2
View File
@@ -32,7 +32,10 @@ build_core_on_linux:
cd $(CORE_PATH) \
&& CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(CORE_NAME) $(CORE_MAIN)
build_agent_on_linux:
prepare_vm_helper:
bash $(AGENT_PATH)/utils/vm/helper/prepare.sh
build_agent_on_linux: prepare_vm_helper
cd $(AGENT_PATH) \
&& CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(AGENT_NAME) $(AGENT_MAIN)
@@ -40,7 +43,7 @@ build_core_on_darwin:
cd $(CORE_PATH) \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(CORE_NAME) $(CORE_MAIN)
build_agent_on_darwin:
build_agent_on_darwin: prepare_vm_helper
cd $(AGENT_PATH) \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(AGENT_NAME) $(AGENT_MAIN)
+20
View File
@@ -826,6 +826,26 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
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
// @Summary Get Agent Telegram channel config
// @Accept json
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
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
// @Summary Page Ollama models
// @Accept json
@@ -114,6 +98,18 @@ 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
// @Summary Page Ollama models
// @Accept json
+16
View File
@@ -0,0 +1,16 @@
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)
}
+9 -2
View File
@@ -207,6 +207,7 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
// @Accept json
// @Param key path string true "app key"
// @Success 200 {file} file "app icon"
// @Success 302 "redirect to the official icon URL"
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/icon/:key [get]
@@ -216,12 +217,18 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
helper.BadRequest(c, err)
return
}
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
iconBytes, source, etag, err := appService.GetAppIcon(appKey)
if err != nil {
helper.InternalServer(c, err)
return
}
if appicon.IsRemoteURL(source) {
c.Header("Cache-Control", "no-cache")
c.Redirect(http.StatusFound, source)
return
}
if len(iconBytes) == 0 {
c.Status(http.StatusNoContent)
return
@@ -229,7 +236,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" && filename != "" {
if etag != "" && source != "" {
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
+109
View File
@@ -0,0 +1,109 @@
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())
}
})
}
}
+1
View File
@@ -203,6 +203,7 @@ func (b *BaseApi) GetServices(c *gin.Context) {
// @Tags App
// @Summary Search app update version by install id
// @Accept json
// @Param request body request.AppUpdateVersion true "request"
// @Success 200 {array} dto.AppVersion
// @Security ApiKeyAuth
// @Security Timestamp
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return
}
total, list, err := containerService.Page(req)
total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
data, err := containerService.LoadStatus()
data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
if err != nil {
helper.InternalServer(c, err)
return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth
// @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) {
data, err := containerService.ContainerListStats()
var ids []string
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 {
helper.InternalServer(c, err)
return
@@ -0,0 +1,43 @@
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) GetContainerImageCheckUpdateStatus(c *gin.Context) {
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, status)
}
func (b *BaseApi) ContainerImageCheckUpdate(c *gin.Context) {
taskID, err := service.StartContainerImageCheckUpdate()
if err != nil {
helper.InternalServer(c, err)
return
}
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
status.TaskID = taskID
helper.SuccessWithData(c, status)
}
func (b *BaseApi) SetContainerImageCheckUpdateSchedule(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := service.SetContainerImageCheckUpdateSchedule(*req.Enabled); err != nil {
helper.InternalServer(c, err)
return
}
b.GetContainerImageCheckUpdateStatus(c)
}
-1
View File
@@ -32,7 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
}
req.Password = string(password)
}
if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err)
return
+6 -4
View File
@@ -11,16 +11,18 @@ var ApiGroupApp = new(ApiGroup)
type BaseApi struct{}
var (
vmService = service.NewIVMService()
dashboardService = service.NewIDashboardService()
appService = service.NewIAppService()
appInstallService = service.NewIAppInstalledService()
appIgnoreUpgradeService = service.NewIAppIgnoreUpgradeService()
aiToolService = service.NewIAIToolService()
mcpServerService = service.NewIMcpServerService()
tensorrtLLMService = service.NewITensorRTLLMService()
agentService = service.NewIAgentService()
modelDownloaderService = service.NewIModelDownloaderService()
aiToolService = service.NewIAIToolService()
mcpServerService = service.NewIMcpServerService()
tensorrtLLMService = service.NewITensorRTLLMService()
agentService = service.NewIAgentService()
containerService = service.NewIContainerService()
composeTemplateService = service.NewIComposeTemplateService()
+5
View File
@@ -3,11 +3,16 @@ package v2
import (
"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/service"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
helper.SuccessWithData(c, service.LoadModelGPUDevices())
}
// @Tags AI
// @Summary Load gpu / xpu info
// @Accept json
+26
View File
@@ -22,6 +22,7 @@ import (
)
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
applyErrorMetadata(ctx, code, msgKey, err)
res := dto.Response{
Code: code,
Message: "",
@@ -32,6 +33,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
}
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
applyErrorMetadata(ctx, code, msgKey, err)
res := dto.Response{
Code: code,
ErrorCode: businessCode,
@@ -42,6 +44,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
}
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
applyErrorMetadata(ctx, code, msgKey, err)
res := dto.Response{
Code: code,
Data: data,
@@ -205,3 +208,26 @@ func SetOperationLogValues(c *gin.Context, values map[string]string) {
c.Header(header, base64.RawURLEncoding.EncodeToString(data))
}
}
const errorMetadataKey = "error_response_metadata_policies"
// ErrorMetadataPolicy adds transport metadata before the existing error response is written.
type ErrorMetadataPolicy func(*gin.Context, int, string, error)
func WithErrorMetadata(policies ...ErrorMetadataPolicy) gin.HandlerFunc {
configured := append([]ErrorMetadataPolicy(nil), policies...)
return func(c *gin.Context) {
c.Set(errorMetadataKey, configured)
c.Next()
}
}
func applyErrorMetadata(c *gin.Context, code int, messageKey string, err error) {
value, _ := c.Get(errorMetadataKey)
policies, _ := value.([]ErrorMetadataPolicy)
for _, policy := range policies {
if policy != nil {
policy(c, code, messageKey, err)
}
}
}
+31
View File
@@ -1,8 +1,12 @@
package v2
import (
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
_ "github.com/1Panel-dev/1Panel/agent/utils/dockerhub" // Swagger response schema.
"github.com/gin-gonic/gin"
)
@@ -225,3 +229,30 @@ func (b *BaseApi) ImageLoad(c *gin.Context) {
helper.Success(c)
}
// @Tags Container Image
// @Summary Search Docker Hub images
// @Accept json
// @Param request body dto.ImageRemoteSearch true "request"
// @Produce json
// @Success 200 {object} dockerhub.SearchResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/image/remote/search [post]
func (b *BaseApi) SearchRemoteImages(c *gin.Context) {
var req dto.ImageRemoteSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
req.Query = strings.TrimSpace(req.Query)
if req.Query == "" {
helper.BadRequest(c, fmt.Errorf("search query is required"))
return
}
result, err := imageService.SearchRemoteImages(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
+137
View File
@@ -0,0 +1,137 @@
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)
}
+29
View File
@@ -0,0 +1,29 @@
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"
}
+390
View File
@@ -0,0 +1,390 @@
package v2
import (
"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/service"
"github.com/gin-gonic/gin"
)
func handleModelDownloaderError(c *gin.Context, err error) {
if service.IsModelDownloaderInputError(err) {
helper.BadRequest(c, err)
return
}
helper.InternalServer(c, err)
}
// @Tags AI
// @Summary Search Hugging Face models
// @Accept json
// @Param request body dto.ModelSearch true "request"
// @Produce json
// @Success 200 {object} dto.PageResult{items=[]dto.ModelItem}
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/hf/search [post]
func (b *BaseApi) SearchHFModels(c *gin.Context) {
var req dto.ModelSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, items, err := modelDownloaderService.SearchHFModels(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Total: total, Items: items})
}
// @Tags AI
// @Summary Get Hugging Face model details
// @Accept json
// @Param request body dto.ModelInfoReq true "request"
// @Produce json
// @Success 200 {object} dto.ModelInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/hf/info [post]
func (b *BaseApi) GetHFModelInfo(c *gin.Context) {
var req dto.ModelInfoReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := modelDownloaderService.GetHFModelInfo(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags AI
// @Summary Download Hugging Face model
// @Description Creates an asynchronous download task. Query /ai/model/downloader/tasks/search for progress.
// @Accept json
// @Param request body dto.ModelDownload true "request"
// @Produce json
// @Success 200 {object} dto.ModelDownloadTaskItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/hf/download [post]
// @x-panel-log {"bodyKeys":["repoID","revision"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"下载 Hugging Face 模型 [repoID]","formatEN":"download Hugging Face model [repoID]"}
func (b *BaseApi) StartHFModelDownload(c *gin.Context) {
var req dto.ModelDownload
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
task, err := modelDownloaderService.StartDownload(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, task)
}
// @Tags AI
// @Summary Search ModelScope models
// @Accept json
// @Param request body dto.ModelSearch true "request"
// @Produce json
// @Success 200 {object} dto.PageResult{items=[]dto.ModelItem}
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/modelscope/search [post]
func (b *BaseApi) SearchModelScopeModels(c *gin.Context) {
var req dto.ModelSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, items, err := modelDownloaderService.SearchModelScopeModels(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Total: total, Items: items})
}
// @Tags AI
// @Summary Get ModelScope model details
// @Accept json
// @Param request body dto.ModelInfoReq true "request"
// @Produce json
// @Success 200 {object} dto.ModelInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/modelscope/info [post]
func (b *BaseApi) GetModelScopeModelInfo(c *gin.Context) {
var req dto.ModelInfoReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := modelDownloaderService.GetModelScopeModelInfo(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags AI
// @Summary Download ModelScope model
// @Description Creates an asynchronous download task. Query /ai/model/downloader/tasks/search for progress.
// @Accept json
// @Param request body dto.ModelDownload true "request"
// @Produce json
// @Success 200 {object} dto.ModelDownloadTaskItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/modelscope/download [post]
// @x-panel-log {"bodyKeys":["repoID","revision"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"下载 ModelScope 模型 [repoID]","formatEN":"download ModelScope model [repoID]"}
func (b *BaseApi) StartModelScopeModelDownload(c *gin.Context) {
var req dto.ModelDownload
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
task, err := modelDownloaderService.StartModelScopeDownload(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, task)
}
// @Tags AI
// @Summary Get model downloader settings
// @Produce json
// @Success 200 {object} dto.ModelDownloaderSetting
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/settings [get]
func (b *BaseApi) GetModelDownloaderSettings(c *gin.Context) {
settings, err := modelDownloaderService.GetSettings()
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, settings)
}
// @Tags AI
// @Summary Update model downloader settings
// @Description Updates the download directory, source endpoints and tokens. The featuredModelsSync field is ignored; use /ai/model/downloader/featured/sync/status to update automatic synchronization.
// @Accept json
// @Param request body dto.ModelDownloaderSetting true "request"
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/settings [post]
// @x-panel-log {"bodyKeys":["modelDir"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新模型下载设置 [modelDir]","formatEN":"update model downloader settings [modelDir]"}
func (b *BaseApi) UpdateModelDownloaderSettings(c *gin.Context) {
var req dto.ModelDownloaderSetting
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := modelDownloaderService.UpdateSettings(req); err != nil {
handleModelDownloaderError(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page model download tasks
// @Description Set includeLocal to true to include locally downloaded models.
// @Accept json
// @Param request body dto.ModelDownloadTaskSearch true "request"
// @Produce json
// @Success 200 {object} dto.PageResult{items=[]dto.ModelDownloadTaskItem}
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/tasks/search [post]
func (b *BaseApi) PageModelDownloadTasks(c *gin.Context) {
var req dto.ModelDownloadTaskSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, items, err := modelDownloaderService.PageTasks(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
}
// @Tags AI
// @Summary Cancel model download task
// @Description Only waiting or downloading tasks can be canceled. The withFile field is ignored.
// @Accept json
// @Param request body dto.ModelDownloadTaskOperate true "request"
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/tasks/cancel [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"model_download_tasks","output_column":"repo_id","output_value":"repoID"}],"formatZH":"取消模型下载任务 [repoID]","formatEN":"cancel model download task [repoID]"}
func (b *BaseApi) CancelModelDownloadTask(c *gin.Context) {
var req dto.ModelDownloadTaskOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := modelDownloaderService.CancelTask(req); err != nil {
handleModelDownloaderError(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Retry model download task
// @Description Only failed or canceled tasks can be retried. Returns the task while the download runs asynchronously.
// @Accept json
// @Param request body dto.ModelDownloadTaskRetry true "request"
// @Produce json
// @Success 200 {object} dto.ModelDownloadTaskItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/tasks/retry [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"model_download_tasks","output_column":"repo_id","output_value":"repoID"}],"formatZH":"重试模型下载任务 [repoID]","formatEN":"retry model download task [repoID]"}
func (b *BaseApi) RetryModelDownloadTask(c *gin.Context) {
var req dto.ModelDownloadTaskRetry
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
task, err := modelDownloaderService.RetryTask(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, task)
}
// @Tags AI
// @Summary Remove model download task
// @Description Waiting or downloading tasks cannot be removed. Set withFile to true to also delete downloaded files.
// @Accept json
// @Param request body dto.ModelDownloadTaskOperate true "request"
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/tasks/remove [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"model_download_tasks","output_column":"repo_id","output_value":"repoID"}],"formatZH":"移除模型下载任务 [repoID]","formatEN":"remove model download task [repoID]"}
func (b *BaseApi) RemoveModelDownloadTask(c *gin.Context) {
var req dto.ModelDownloadTaskOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := modelDownloaderService.RemoveTask(req); err != nil {
handleModelDownloaderError(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page locally downloaded models
// @Accept json
// @Param request body dto.LocalModelSearch true "request"
// @Produce json
// @Success 200 {object} dto.PageResult{items=[]dto.LocalModelItem}
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/local/search [post]
func (b *BaseApi) PageLocalDownloadedModels(c *gin.Context) {
var req dto.LocalModelSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, items, err := modelDownloaderService.ListLocalModels(req)
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
}
// @Tags AI
// @Summary Delete locally downloaded model
// @Accept json
// @Param request body dto.LocalModelDelete true "request"
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/local/delete [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除本地模型 [name]","formatEN":"delete local model [name]"}
func (b *BaseApi) DeleteLocalDownloadedModel(c *gin.Context) {
var req dto.LocalModelDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := modelDownloaderService.DeleteLocalModel(req); err != nil {
handleModelDownloaderError(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Synchronize featured models
// @Description Synchronizes the featured model catalog immediately, independently of the automatic synchronization setting. Does nothing in offline mode.
// @Accept json
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/featured/sync [post]
func (b *BaseApi) SyncFeaturedModels(c *gin.Context) {
if err := modelDownloaderService.SyncFeaturedModels(); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page featured models
// @Accept json
// @Param request body dto.FeaturedModelSearch true "request"
// @Produce json
// @Success 200 {object} dto.FeaturedModelPage{items=[]dto.FeaturedModel}
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/featured/search [post]
func (b *BaseApi) PageFeaturedModels(c *gin.Context) {
var req dto.FeaturedModelSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
result, err := modelDownloaderService.PageFeaturedModels(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags AI
// @Summary Update featured model automatic synchronization
// @Description Set value to Enable or Disable. This updates the automatic synchronization setting without triggering an immediate synchronization.
// @Accept json
// @Param request body dto.ModelDownloaderSyncSetting true "request"
// @Produce json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/model/downloader/featured/sync/status [post]
// @x-panel-log {"bodyKeys":["value"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新推荐模型自动同步 [value]","formatEN":"update featured model automatic synchronization [value]"}
func (b *BaseApi) UpdateFeaturedModelsSync(c *gin.Context) {
var req dto.ModelDownloaderSyncSetting
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := modelDownloaderService.UpdateFeaturedModelsSync(req); err != nil {
handleModelDownloaderError(c, err)
return
}
helper.Success(c)
}
+84
View File
@@ -0,0 +1,84 @@
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)
}
+3 -2
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /runtimes/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"}
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := runtimeService.Delete(req)
name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil {
helper.InternalServer(c, err)
return
+12 -3
View File
@@ -5,6 +5,8 @@ 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/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := taskService.Page(req)
total, list, err := taskServiceForRequest(c).Page(req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return
}
var res *response.FileLineContent
res, err := taskService.ReadByLine(req)
res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp
// @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask()
count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil {
helper.InternalServer(c, err)
return
}
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
}
+6 -2
View File
@@ -19,7 +19,11 @@ func (b *BaseApi) PageTensorRTLLMs(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list := tensorrtLLMService.Page(req)
list, err := tensorrtLLMService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, list)
}
@@ -81,7 +85,7 @@ func (b *BaseApi) DeleteTensorRTLLM(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := tensorrtLLMService.Delete(req.ID)
err := tensorrtLLMService.Delete(req)
if err != nil {
helper.InternalServer(c, err)
return
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @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) {
var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"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/buserr"
"github.com/gin-gonic/gin"
)
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
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)
}
+63 -36
View File
@@ -9,7 +9,8 @@ type AgentCreateReq struct {
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
AccessHost string `json:"accessHost"`
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
@@ -110,6 +111,7 @@ type AgentBatchOperateResult struct {
}
type AgentItem struct {
AccessHost string `json:"accessHost"`
ID uint `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
@@ -316,6 +318,7 @@ type AgentAccountModelDiscoverReq struct {
}
type AgentAccountModelCreateReq struct {
TaskID string `json:"taskID"`
AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"`
}
@@ -326,8 +329,9 @@ type AgentAccountModelUpdateReq struct {
}
type AgentAccountModelDeleteReq struct {
AccountID uint `json:"accountId" validate:"required"`
RecordID uint `json:"recordId" validate:"required"`
DeleteFiles bool `json:"deleteFiles"`
AccountID uint `json:"accountId" validate:"required"`
RecordID uint `json:"recordId" validate:"required"`
}
type AgentAccountCreateReq struct {
@@ -345,17 +349,18 @@ type AgentAccountCreateReq struct {
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
Models []AgentAccountModel `json:"models"`
}
type AgentAccountVerifyReq struct {
@@ -384,6 +389,7 @@ type AgentAccountProviderCountReq struct {
}
type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"`
@@ -430,32 +436,53 @@ type AgentFeishuConfigReq struct {
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 {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode" validate:"required"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode" validate:"required"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
}
type AgentFeishuConfig struct {
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
BuiltinManaged bool `json:"builtinManaged"`
LegacyConfigured bool `json:"legacyConfigured"`
GroupSessionScope string `json:"groupSessionScope"`
BuiltinAvailable bool `json:"builtinAvailable"`
Legacy bool `json:"legacy"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentTelegramConfigReq struct {
+28 -5
View File
@@ -2,6 +2,28 @@ package dto
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 {
ID uint `json:"id"`
Name string `json:"name"`
@@ -14,16 +36,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"`
}
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct {
Name string `json:"name"`
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 {
Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"`
+13
View File
@@ -0,0 +1,13 @@
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"`
}
+5 -3
View File
@@ -43,9 +43,11 @@ type AppOssConfig struct {
}
type AppVersion struct {
Version string `json:"version"`
DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"`
FeishuLegacy bool `json:"feishuLegacy,omitempty"`
FeishuConfigured bool `json:"feishuConfigured,omitempty"`
Version string `json:"version"`
DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"`
}
type AppList struct {
+12 -8
View File
@@ -21,13 +21,16 @@ type InspectReq struct {
}
type ContainerInfo struct {
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
Network []string `json:"network"`
Ports []string `json:"ports"`
@@ -171,7 +174,8 @@ type ContainerItemStats struct {
BuildCacheReclaimable int64 `json:"buildCacheReclaimable"`
}
type ContainerListStats struct {
ContainerID string `json:"containerID"`
ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"`
+22 -1
View File
@@ -31,7 +31,27 @@ type DashboardBase struct {
}
type ChangeQuicks struct {
Quicks []QuickJump `json:"quicks"`
Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
}
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 {
@@ -157,6 +177,7 @@ type DiskInfo struct {
}
type GPUInfo struct {
SharedMemory bool `json:"sharedMemory"`
Type string `json:"type"`
Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"`
+6
View File
@@ -2,6 +2,12 @@ package dto
import "time"
type ImageRemoteSearch struct {
Query string `json:"query" validate:"required,max=256"`
Page int `json:"page" validate:"required,min=1"`
PageSize int `json:"pageSize" validate:"required,min=1,max=100"`
}
type PageImage struct {
PageInfo
Name string `json:"name"`
+143
View File
@@ -0,0 +1,143 @@
package dto
type ModelSearch struct {
Query string `json:"query"`
Sort string `json:"sort" validate:"omitempty,oneof=trending downloads updated created likes most_params least_params"`
Page int `json:"page" validate:"omitempty,min=1"`
PageSize int `json:"pageSize" validate:"omitempty,min=1,max=100"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
}
type ModelInfoReq struct {
RepoID string `json:"repoID" validate:"required"`
Revision string `json:"revision"`
}
type ModelDownload struct {
DownloadType string `json:"downloadType" validate:"omitempty,oneof=all tokenizer"`
RepoID string `json:"repoID" validate:"required"`
Revision string `json:"revision"`
}
type ModelDownloaderSyncSetting struct {
Value string `json:"value" validate:"required,oneof=Enable Disable"`
}
type ModelDownloaderSetting struct {
FeaturedModelsSync string `json:"featuredModelsSync"`
ModelDir string `json:"modelDir" validate:"required"`
HFEndpoint string `json:"hfEndpoint"`
HFToken string `json:"hfToken"`
ModelScopeEndpoint string `json:"modelScopeEndpoint"`
ModelScopeToken string `json:"modelScopeToken"`
}
type ModelItem struct {
RepoID string `json:"repoID"`
Name string `json:"name"`
Downloads int64 `json:"downloads"`
Likes int64 `json:"likes"`
Size int64 `json:"size"`
SizeFormatted string `json:"sizeFormatted"`
}
type ModelFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
SizeFormatted string `json:"sizeFormatted"`
}
type ModelInfo struct {
ModelItem
ModelCard string `json:"modelCard"`
Files []ModelFile `json:"files"`
}
type ModelDownloadTaskSearch struct {
IncludeLocal bool `json:"includeLocal"`
DownloadType string `json:"downloadType" validate:"omitempty,oneof=all tokenizer"`
PageInfo
Info string `json:"info"`
Status string `json:"status"`
}
type ModelDownloadTaskOperate struct {
WithFile bool `json:"withFile"`
ID uint `json:"id" validate:"required"`
}
type ModelDownloadTaskRetry struct {
ID uint `json:"id" validate:"required"`
}
type ModelDownloadTaskItem struct {
LocalModel *LocalModelItem `json:"localModel,omitempty"`
DownloadType string `json:"downloadType"`
ID uint `json:"id"`
Source string `json:"source"`
RepoID string `json:"repoID"`
Revision string `json:"revision"`
ModelName string `json:"modelName"`
TargetDir string `json:"targetDir"`
Status string `json:"status"`
Progress float64 `json:"progress"`
TotalSize int64 `json:"totalSize"`
DownloadedSize int64 `json:"downloadedSize"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
StartedAt string `json:"startedAt"`
CompletedAt string `json:"completedAt"`
CanCancel bool `json:"canCancel"`
CanRetry bool `json:"canRetry"`
CanRemoveRecord bool `json:"canRemoveRecord"`
}
type LocalModelSearch struct {
DownloadType string `json:"downloadType" validate:"omitempty,oneof=all tokenizer"`
PageInfo
Info string `json:"info"`
}
type LocalModelItem struct {
DownloadType string `json:"downloadType"`
RepoID string `json:"repoID"`
Source string `json:"source"`
Revision string `json:"revision"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
SizeFormatted string `json:"sizeFormatted"`
CreatedAt string `json:"createdAt"`
}
type LocalModelDelete struct {
WithFile bool `json:"withFile"`
DownloadType string `json:"downloadType" validate:"omitempty,oneof=all tokenizer"`
Name string `json:"name" validate:"required"`
}
type FeaturedModel struct {
Key string `json:"key" yaml:"key"`
Sort int `json:"sort" yaml:"sort"`
Family string `json:"family" yaml:"category"`
RepoID string `json:"repoID" yaml:"name"`
Sources []string `json:"sources" yaml:"sources"`
SourceModelIDs map[string]string `json:"sourceModelIds" yaml:"sourceModelIds"`
Architecture string `json:"architecture" yaml:"modelType"`
BackboneParams string `json:"backboneParams" yaml:"moeBackboneParameters"`
ActiveParams string `json:"activeParams" yaml:"activeParameters"`
Engram string `json:"engram" yaml:"engramParameters"`
WeightGB float64 `json:"weightGB" yaml:"approximateWeightSizeGB"`
Description map[string]string `json:"description" yaml:"description"`
}
type FeaturedModelSearch struct {
PageInfo
Family string `json:"family"`
Info string `json:"info"`
}
type FeaturedModelPage struct {
PageResult
Families []string `json:"families"`
}
+12 -11
View File
@@ -56,17 +56,18 @@ type MonitorGPUOptions struct {
Options []string `json:"options"`
}
type GPUChartHide struct {
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"`
Type string `json:"type"`
Process bool `json:"process"`
GPU bool `json:"gpu"`
Memory bool `json:"memory"`
Power bool `json:"power"`
PowerLimit bool `json:"powerLimit"`
Temperature bool `json:"temperature"`
Speed bool `json:"speed"`
SharedMemory bool `json:"sharedMemory"`
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"`
Type string `json:"type"`
Process bool `json:"process"`
GPU bool `json:"gpu"`
Memory bool `json:"memory"`
Power bool `json:"power"`
PowerLimit bool `json:"powerLimit"`
Temperature bool `json:"temperature"`
Speed bool `json:"speed"`
}
type MonitorGPUSearch struct {
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
+37 -23
View File
@@ -22,6 +22,13 @@ type AppInstallCreate struct {
Name string `json:"name" validate:"required"`
Services map[string]string `json:"services"`
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
NodePushConfig
@@ -83,31 +90,33 @@ type AppBackupDelete struct {
}
type AppInstalledOperate struct {
InstallId uint `json:"installId" validate:"required"`
BackupId uint `json:"backupId"`
DetailId uint `json:"detailId"`
Operate constant.AppOperate `json:"operate" validate:"required"`
ForceDelete bool `json:"forceDelete"`
DeleteBackup bool `json:"deleteBackup"`
DeleteDB bool `json:"deleteDB"`
Backup bool `json:"backup"`
PullImage bool `json:"pullImage"`
DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"`
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallId uint `json:"installId" validate:"required"`
BackupId uint `json:"backupId"`
DetailId uint `json:"detailId"`
Operate constant.AppOperate `json:"operate" validate:"required"`
ForceDelete bool `json:"forceDelete"`
DeleteBackup bool `json:"deleteBackup"`
DeleteDB bool `json:"deleteDB"`
Backup bool `json:"backup"`
PullImage bool `json:"pullImage"`
DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"`
UseLifecycleScripts bool `json:"-"`
}
type AppInstallUpgrade struct {
InstallID uint `json:"installId"`
DetailID uint `json:"detailId"`
Backup bool `json:"backup"`
PullImage bool `json:"pullImage"`
DeleteImage bool `json:"deleteImage"`
DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallID uint `json:"installId"`
DetailID uint `json:"detailId"`
Backup bool `json:"backup"`
PullImage bool `json:"pullImage"`
DeleteImage bool `json:"deleteImage"`
DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
}
type AppInstallDelete struct {
@@ -122,9 +131,13 @@ type AppInstallDelete struct {
}
type AppInstalledUpdate struct {
InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"`
GPU *dto.ModelGPUConfig `json:"gpu"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"`
AppContainerConfig
}
@@ -147,6 +160,7 @@ type PortUpdate struct {
type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"`
LoadCompose *bool `json:"loadCompose,omitempty"`
}
type AppInstallSortItem struct {
+15
View File
@@ -0,0 +1,15 @@
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"`
DeleteWebsite bool `json:"deleteWebsite,omitempty"`
}
+14 -10
View File
@@ -8,14 +8,16 @@ type TensorRTLLMSearch struct {
}
type TensorRTLLMCreate struct {
Name string `json:"name" validate:"required"`
ContainerName string `json:"containerName" validate:"required"`
Version string `json:"version" validate:"required"`
ModelDir string `json:"modelDir" validate:"required"`
Image string `json:"image" validate:"required"`
Command string `json:"command" validate:"required"`
ModelType string `json:"modelType"`
ModelSpeedup bool `json:"modelSpeedup"`
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required"`
ContainerName string `json:"containerName" validate:"required"`
Version string `json:"version" validate:"required"`
ModelDir string `json:"modelDir" validate:"required"`
Image string `json:"image" validate:"required"`
Command string `json:"command" validate:"required"`
ModelType string `json:"modelType"`
ModelSpeedup bool `json:"modelSpeedup"`
GPU *dto.ModelGPUConfig `json:"gpu"`
DockerConfig
}
@@ -25,12 +27,14 @@ type TensorRTLLMUpdate struct {
}
type TensorRTLLMDelete struct {
ID uint `json:"id" validate:"required"`
ID uint `json:"id" validate:"required"`
TaskID string `json:"taskID"`
}
type TensorRTLLMOperate struct {
TaskID string `json:"taskID"`
ID uint `json:"id" validate:"required"`
Operate string `json:"operate" validate:"required"`
Operate string `json:"operate" validate:"required,oneof=start stop restart"`
}
type DockerConfig struct {
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"`
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"`
}
+28 -15
View File
@@ -31,6 +31,7 @@ type AppDTO struct {
type AppItem struct {
Name string `json:"name"`
Key string `json:"key"`
Icon string `json:"icon,omitempty"`
ID uint `json:"id"`
Description string `json:"description"`
Status string `json:"status"`
@@ -50,19 +51,22 @@ type TagDTO struct {
}
type AppInstalledCheck struct {
IsExist bool `json:"isExist"`
Name string `json:"name"`
App string `json:"app"`
Version string `json:"version"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
LastBackupAt string `json:"lastBackupAt"`
AppInstallID uint `json:"appInstallId"`
ContainerName string `json:"containerName"`
InstallPath string `json:"installPath"`
HttpPort int `json:"httpPort"`
HttpsPort int `json:"httpsPort"`
WebsiteDir string `json:"websiteDir"`
TaskID string `json:"taskID"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"`
Name string `json:"name"`
App string `json:"app"`
Version string `json:"version"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
LastBackupAt string `json:"lastBackupAt"`
AppInstallID uint `json:"appInstallId"`
ContainerName string `json:"containerName"`
InstallPath string `json:"installPath"`
HttpPort int `json:"httpPort"`
HttpsPort int `json:"httpsPort"`
WebsiteDir string `json:"websiteDir"`
}
type AppDetailDTO struct {
@@ -104,6 +108,7 @@ type AppDetail struct {
}
type AppInstallDTO struct {
TaskID string `json:"taskID"`
ID uint `json:"id"`
Name string `json:"name"`
AppID uint `json:"appID"`
@@ -184,7 +189,15 @@ type AppParam struct {
}
type AppConfig struct {
Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"`
GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
AppKey string `json:"appKey"`
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
}
+18
View File
@@ -0,0 +1,18 @@
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"`
WebsiteDeleted bool `json:"websiteDeleted,omitempty"`
}
+9 -6
View File
@@ -1,6 +1,7 @@
package response
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/model"
)
@@ -11,13 +12,15 @@ type TensorRTLLMsRes struct {
}
type TensorRTLLMDTO struct {
TaskID string `json:"taskID"`
model.TensorRTLLM
Version string `json:"version"`
Model string `json:"model"`
Dir string `json:"dir"`
ModelDir string `json:"modelDir"`
Image string `json:"image"`
Command string `json:"command"`
Version string `json:"version"`
Model string `json:"model"`
Dir string `json:"dir"`
ModelDir string `json:"modelDir"`
Image string `json:"image"`
Command string `json:"command"`
GPU *dto.ModelGPUConfig `json:"gpu"`
ExposedPorts []request.ExposedPort `json:"exposedPorts"`
Environments []request.Environment `json:"environments"`
+447
View File
@@ -0,0 +1,447 @@
package dto
import (
"time"
)
type VMSearchResult struct {
PageResult
Limit int `json:"limit"` // Maximum VM count for the target node; 0 means unlimited.
}
type VMCheckItem struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Message string `json:"message"`
IsEnable bool `json:"isEnable"`
IsExist bool `json:"isExist"`
IsActive bool `json:"isActive"`
}
type VMEnvironment struct {
Arch string `json:"arch"`
ArchSupported bool `json:"archSupported"`
Ready bool `json:"ready"`
HelperExist bool `json:"helperExist"`
HelperMessage string `json:"helperMessage"`
KVMExist bool `json:"kvmExist"`
KVMMessage string `json:"kvmMessage"`
LibvirtExist bool `json:"libvirtExist"`
LibvirtActive bool `json:"libvirtActive"`
LibvirtMessage string `json:"libvirtMessage"`
StorageExist bool `json:"storageExist"`
StorageMessage string `json:"storageMessage"`
}
type VMHealthCheckItem struct {
Level string `json:"level"`
Check string `json:"check"`
Reason string `json:"reason,omitempty"`
Params map[string]string `json:"params,omitempty"`
Detail string `json:"detail,omitempty"`
FixCommand string `json:"fixCommand,omitempty"`
}
type VMHealthCheckResult struct {
Pass int `json:"pass"`
Warn int `json:"warn"`
Fail int `json:"fail"`
Items []VMHealthCheckItem `json:"items"`
}
type VMCreate struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
CPU int `json:"cpu" validate:"required,min=1"`
Memory int64 `json:"memory" validate:"required,min=1"`
DiskSize int64 `json:"diskSize" validate:"required,min=1"`
OSType string `json:"osType" validate:"omitempty,oneof=windows linux other"`
DiskBus string `json:"diskBus" validate:"omitempty,oneof=sata virtio"`
TemplateID uint `json:"templateID"`
DiskPath string `json:"diskPath"`
StoragePool string `json:"storagePool"`
ISOPaths []string `json:"isoPaths"`
ImagePath string `json:"imagePath"`
Network string `json:"network" validate:"required,vm_common"`
BootOrder string `json:"bootOrder"`
StartAfterCreate bool `json:"startAfterCreate"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
}
type VMTemplateCreate struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
TemplateName string `json:"templateName" validate:"required,vm_name"`
StoragePool string `json:"storagePool"`
Description string `json:"description"`
}
type VMTemplateDelete struct {
ID uint `json:"id" validate:"required"`
DeleteDisk bool `json:"deleteDisk"`
TaskID string `json:"taskID"`
}
type VMTemplateInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
SourceVM string `json:"sourceVM"`
CPU int `json:"cpu"`
Memory int64 `json:"memory"`
DiskSize int64 `json:"diskSize"`
DiskBus string `json:"diskBus"`
DiskPath string `json:"diskPath"`
StoragePool string `json:"storagePool"`
Network string `json:"network"`
BootOrder string `json:"bootOrder"`
IsExist bool `json:"isExist"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description"`
}
type VMUpdate struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
CPU int `json:"cpu" validate:"required,min=1"`
Memory int64 `json:"memory" validate:"required,min=1"`
ISOPaths []string `json:"isoPaths"`
Network string `json:"network" validate:"required,vm_common"`
BootOrder string `json:"bootOrder"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
}
type VMDiskResize struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
Target string `json:"target" validate:"required,vm_common"`
SizeGiB int64 `json:"sizeGiB" validate:"required,min=1,max=8388607"`
}
type VMRename struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
NewName string `json:"newName" validate:"required,vm_name"`
}
type VMDelete struct {
TaskID string `json:"taskID"`
ID uint `json:"id" validate:"required"`
Force bool `json:"force"`
}
type VMOperate struct {
Name string `json:"name" validate:"required,vm_name"`
Operate string `json:"operate" validate:"required,oneof=start shutdown reboot destroy suspend resume"`
}
type VMInfoReq struct {
Name string `json:"name" validate:"required,vm_name"`
CheckDisks bool `json:"checkDisks"`
}
type VMReq struct {
Name string `json:"name" validate:"required,vm_name"`
}
type VMTaskResp struct {
TaskID string `json:"taskID"`
}
type VMOrphanItem struct {
Type string `json:"type" validate:"required,oneof=orphan_domain_disk orphan_snapshot partial_file"`
Path string `json:"path" validate:"required"`
Size int64 `json:"size"`
ModifiedAt int64 `json:"modifiedAt"`
}
type VMOrphanClean struct {
Items []VMOrphanItem `json:"items" validate:"required,min=1,dive"`
}
type VMSnapshotReq struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required,vm_name"`
SnapshotName string `json:"snapshotName" validate:"required,vm_name"`
Description string `json:"description"`
}
type VMSnapshotSearch struct {
Name string `json:"name" validate:"required,vm_name"`
}
type VMSnapshotInfo struct {
Name string `json:"name"`
Description string `json:"description"`
CreatedAt int64 `json:"createdAt"`
State string `json:"state"`
Parent string `json:"parent"`
Path string `json:"path"`
Size int64 `json:"size"`
Current bool `json:"current"`
Children int `json:"children"`
}
type VMIsoSearch struct {
PageInfo
Type string `json:"type" validate:"required"`
Info string `json:"info"`
}
type VMIsoCreate struct {
Type string `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
Description string `json:"description"`
}
type VMIsoUpdate struct {
ID uint `json:"id" validate:"required"`
Type string `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
Description string `json:"description"`
}
type VMIsoImportStart struct {
Type string `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
Status string `json:"status" validate:"required,oneof=uploading downloading"`
TempFileID string `json:"tempFileID"`
TempFileType string `json:"tempFileType" validate:"omitempty,oneof=upload download"`
Description string `json:"description"`
}
type VMIsoImportUpdate struct {
ID uint `json:"id" validate:"required"`
Status string `json:"status" validate:"required,oneof=uploading downloading ready failed"`
Error string `json:"error"`
TempFileID string `json:"tempFileID"`
TempFileType string `json:"tempFileType" validate:"omitempty,oneof=upload download"`
}
type VMIsoDelete struct {
ID uint `json:"id" validate:"required"`
DeleteFile bool `json:"deleteFile"`
}
type VMIsoInfo struct {
ID uint `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
IsExist bool `json:"isExist"`
Size int64 `json:"size"`
Status string `json:"status"`
Error string `json:"error"`
TempFileID string `json:"tempFileID"`
TempFileType string `json:"tempFileType"`
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description"`
}
type VMResourceInfo struct {
Type string `json:"type"`
Name string `json:"name"`
SubType string `json:"subType"`
BridgeName string `json:"bridgeName"`
SourceInterface string `json:"sourceInterface"`
CIDR string `json:"cidr"`
Gateway string `json:"gateway"`
DHCPStart string `json:"dhcpStart"`
DHCPEnd string `json:"dhcpEnd"`
Path string `json:"path"`
Status string `json:"status"`
AutoStart bool `json:"autoStart"`
Capacity uint64 `json:"capacity"`
Available uint64 `json:"available"`
}
type VMResourceDelete struct {
ID uint `json:"id" validate:"required"`
Force bool `json:"force"`
}
type VMNetworkCreate struct {
Name string `json:"name" validate:"required,vm_common"`
Type string `json:"type" validate:"required,oneof=nat bridge macvtap"`
BridgeName string `json:"bridgeName"`
SourceInterface string `json:"sourceInterface" validate:"required_if=Type macvtap"`
CIDR string `json:"cidr"`
Gateway string `json:"gateway"`
DHCPStart string `json:"dhcpStart"`
DHCPEnd string `json:"dhcpEnd"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
}
type VMNetworkUpdate struct {
ID uint `json:"id" validate:"required"`
AutoStart *bool `json:"autoStart"`
Status *string `json:"status"`
Description *string `json:"description"`
}
type VMNetworkItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
BridgeName string `json:"bridgeName"`
SourceInterface string `json:"sourceInterface"`
CIDR string `json:"cidr"`
Gateway string `json:"gateway"`
DHCPStart string `json:"dhcpStart"`
DHCPEnd string `json:"dhcpEnd"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
CreatedAt string `json:"createdAt"`
Status string `json:"status"`
VMCount int `json:"vmCount"`
VMs []VMResourceRef `json:"vms"`
Registered bool `json:"registered"`
UsedIPs []VMNetworkIPUsage `json:"usedIPs"`
Error string `json:"error"`
}
type VMResourceRef struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type VMNetworkIPUsage struct {
IP string `json:"ip"`
VMName string `json:"vmName"`
MAC string `json:"mac"`
Source string `json:"source"`
}
type VMNetworkInterfaceItem struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
}
type VMNetworkBridgeItem struct {
Name string `json:"name"`
Recommended bool `json:"recommended"`
Description string `json:"description"`
}
type VMStorageCreate struct {
Name string `json:"name" validate:"required,vm_common"`
Type string `json:"type" validate:"required,oneof=dir"`
Path string `json:"path" validate:"required"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
}
type VMStorageUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"omitempty,vm_common"`
Path string `json:"path"`
AutoStart *bool `json:"autoStart"`
Status *string `json:"status"`
Description *string `json:"description"`
}
type VMStorageItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
AutoStart bool `json:"autoStart"`
Description string `json:"description"`
CreatedAt string `json:"createdAt"`
Status string `json:"status"`
Capacity uint64 `json:"capacity"`
Available uint64 `json:"available"`
VMCount int `json:"vmCount"`
VMs []VMResourceRef `json:"vms"`
Error string `json:"error"`
}
type VMInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
UUID string `json:"uuid"`
CPU int `json:"cpu"`
Memory int64 `json:"memory"`
DiskSize int64 `json:"diskSize"`
DiskBus string `json:"diskBus"`
DiskPath string `json:"diskPath"`
StoragePool string `json:"storagePool"`
ISOPaths []string `json:"isoPaths"`
ImagePath string `json:"imagePath"`
Network string `json:"network"`
BootOrder string `json:"bootOrder"`
AutoStart bool `json:"autoStart"`
Status string `json:"status"`
Disks []VMDisk `json:"disks"`
Interfaces []VMInterface `json:"interfaces"`
Graphics *VMGraphics `json:"graphics"`
TaskID string `json:"taskID"`
CreateTaskID string `json:"createTaskID"`
Description string `json:"description"`
}
type VMDisk struct {
Capacity int64 `json:"capacity"`
Format string `json:"format"`
ReadOnly bool `json:"readOnly"`
Shareable bool `json:"shareable"`
ResizeBlocked string `json:"resizeBlocked"`
Device string `json:"device"`
Type string `json:"type"`
Source string `json:"source"`
Target string `json:"target"`
Bus string `json:"bus"`
}
type VMInterface struct {
Type string `json:"type"`
Source string `json:"source"`
Model string `json:"model"`
MAC string `json:"mac"`
IP string `json:"ip"`
}
type VMGraphics struct {
Type string `json:"type"`
Port string `json:"port"`
Listen string `json:"listen"`
}
type VMStats struct {
Name string `json:"name"`
Status string `json:"status"`
Timestamp int64 `json:"timestamp"`
CPUTime uint64 `json:"cpuTime"`
VCPU int `json:"vcpu"`
Memory VMMemoryStats `json:"memory"`
Disk VMDiskStats `json:"disk"`
Network VMNetworkStats `json:"network"`
}
type VMMemoryStats struct {
AvailableKiB uint64 `json:"availableKiB"`
UnusedKiB uint64 `json:"unusedKiB"`
UsableKiB uint64 `json:"usableKiB"`
RSSKiB uint64 `json:"rssKiB"`
ActualKiB uint64 `json:"actualKiB"`
}
type VMDiskStats struct {
ReadBytes uint64 `json:"readBytes"`
WriteBytes uint64 `json:"writeBytes"`
ReadReqs uint64 `json:"readReqs"`
WriteReqs uint64 `json:"writeReqs"`
}
type VMNetworkStats struct {
RxBytes uint64 `json:"rxBytes"`
TxBytes uint64 `json:"txBytes"`
RxPackets uint64 `json:"rxPackets"`
TxPackets uint64 `json:"txPackets"`
}
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct {
BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
+5
View File
@@ -17,6 +17,7 @@ type AppInstall struct {
Param string `json:"param"`
Env string `json:"env"`
DockerCompose string `json:"dockerCompose" `
TaskID string `json:"taskID" gorm:"-"`
Status string `json:"status" gorm:"not null"`
Description string `json:"description"`
Message string `json:"message"`
@@ -28,6 +29,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"`
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"`
}
+7
View File
@@ -5,6 +5,13 @@ type AppLauncher struct {
Key string `json:"key"`
}
type QuickDirectory struct {
BaseModel
Path string `json:"path"`
Alias string `json:"alias"`
Legacy bool `json:"legacy" gorm:"-"`
}
type QuickJump struct {
BaseModel
Name string `json:"name"`
@@ -0,0 +1,20 @@
package model
import "time"
// ContainerImageCheckUpdate is a cache tied to the exact image used by a container.
type ContainerImageCheckUpdate struct {
ContainerID string `gorm:"primaryKey" json:"containerID"`
ImageID string `json:"imageID"`
ImageName string `json:"imageName"`
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
TargetImageID string `json:"-"`
HasUpdate bool `gorm:"not null;default:false" json:"hasUpdate"`
CheckedAt time.Time `json:"checkedAt"`
CheckStatus string `json:"checkStatus"`
CheckError string `json:"checkError"`
}
// TableName preserves the table created by the original migration.
func (ContainerImageCheckUpdate) TableName() string { return "container_image_updates" }
+17
View File
@@ -0,0 +1,17 @@
package model
type FeaturedModel struct {
BaseModel
Key string `json:"key"`
Sort int `json:"sort"`
Family string `json:"family"`
RepoID string `json:"repoID"`
Sources []string `json:"sources" gorm:"serializer:json"`
SourceModelIDs map[string]string `json:"sourceModelIds" gorm:"serializer:json"`
Architecture string `json:"architecture"`
BackboneParams string `json:"backboneParams"`
ActiveParams string `json:"activeParams"`
Engram string `json:"engram"`
WeightGB float64 `json:"weightGB"`
Description string `json:"description"`
}
+20
View File
@@ -0,0 +1,20 @@
package model
import "time"
type ModelDownloadTask struct {
DownloadType string `json:"downloadType"`
BaseModel
Source string `json:"source" gorm:"type:varchar(32);not null;index"`
RepoID string `json:"repoID" gorm:"type:varchar(255);not null;index"`
Revision string `json:"revision" gorm:"type:varchar(128)"`
ModelName string `json:"modelName" gorm:"type:varchar(255);not null;index"`
TargetDir string `json:"targetDir" gorm:"type:text"`
Status string `json:"status" gorm:"type:varchar(32);not null;index"`
Progress float64 `json:"progress"`
TotalSize int64 `json:"totalSize"`
DownloadedSize int64 `json:"downloadedSize"`
ErrorMessage string `json:"errorMessage" gorm:"type:text"`
StartedAt *time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
}
+81
View File
@@ -0,0 +1,81 @@
package model
type VirtualMachine struct {
BaseModel
Name string `json:"name"`
UUID string `json:"uuid"`
CPU int `json:"cpu"`
Memory int64 `json:"memory"`
DiskSize int64 `json:"diskSize"`
DiskBus string `json:"diskBus"`
DiskPath string `json:"diskPath"`
StoragePool string `json:"storagePool"`
ISOPaths []string `json:"isoPaths" gorm:"serializer:json"`
ImagePath string `json:"imagePath"`
Network string `json:"network"`
BootOrder string `json:"bootOrder"`
AutoStart bool `json:"autoStart"`
Status string `json:"status"`
TaskID string `json:"taskID"`
CreateTaskID string `json:"createTaskID"`
Description string `json:"description"`
}
type VMTemplate struct {
BaseModel
Name string `json:"name"`
SourceVM string `json:"sourceVM"`
CPU int `json:"cpu"`
Memory int64 `json:"memory"`
DiskSize int64 `json:"diskSize"`
DiskBus string `json:"diskBus"`
DiskPath string `json:"diskPath"`
StoragePool string `json:"storagePool"`
Network string `json:"network"`
BootOrder string `json:"bootOrder"`
Description string `json:"description"`
}
type VMIso struct {
BaseModel
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
Status string `json:"status"`
Error string `json:"error"`
TempFileID string `json:"tempFileID"`
TempFileType string `json:"tempFileType"`
Description string `json:"description"`
}
type VMNetwork struct {
BaseModel
Name string `json:"name"`
Type string `json:"type"`
BridgeName string `json:"bridgeName"`
SourceInterface string `json:"sourceInterface"`
CIDR string `json:"cidr" gorm:"column:cidr"`
Gateway string `json:"gateway"`
DHCPStart string `json:"dhcpStart"`
DHCPEnd string `json:"dhcpEnd"`
AutoStart bool `json:"autoStart"`
Status string `json:"status"`
Description string `json:"description"`
Error string `json:"error"`
}
type VMStorage struct {
BaseModel
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
AutoStart bool `json:"autoStart"`
Status string `json:"status"`
Description string `json:"description"`
Error string `json:"error"`
}
+3 -4
View File
@@ -48,7 +48,7 @@ var catalog = map[string]Meta{
},
// llmman (https://github.com/llmmanorg/llmman): local runner with Ollama/OpenAI-compatible routes on 127.0.0.1:17434.
"llmman": {
Key: "llmman", DisplayName: "llmman", Sort: 16, DefaultAPIType: "openai-responses",
Key: "llmman", DisplayName: "llmman", Sort: 75, DefaultAPIType: "openai-responses",
APIConfigs: []APIConfig{
{APIType: "openai-responses", BaseURL: "http://127.0.0.1:17434/v1", EditableBaseURL: true},
{APIType: "openai-completions", BaseURL: "http://127.0.0.1:17434/v1", EditableBaseURL: true},
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
},
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
},
"bailian-coding-plan": {
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
@@ -82,7 +82,6 @@ var catalog = map[string]Meta{
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "glm-5", Name: "GLM-5"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
},
@@ -114,7 +113,7 @@ var catalog = map[string]Meta{
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
},
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"}},
Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
},
"xiaomi": {
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
+100
View File
@@ -0,0 +1,100 @@
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)
}
}
}
}
}
+13 -4
View File
@@ -118,10 +118,15 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
headers["x-api-key"] = apiKey
}
headers["anthropic-version"] = "2023-06-01"
request.Body = mustJSON(map[string]interface{}{
body := map[string]interface{}{
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"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":
request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey
@@ -131,9 +136,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey
}
request.Body = mustJSON(map[string]interface{}{
body := map[string]interface{}{
"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
}
+28
View File
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
@@ -17,7 +18,9 @@ type IAgentAccountRepo interface {
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
Count() (int64, error)
CountTextByProviders(providers []string) (map[string]int64, error)
SaveWithModels(account *model.AgentAccount, models []model.AgentAccountModel) error
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -67,6 +70,12 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
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) {
normalizedProviders := normalizeProviders(providers)
counts := make(map[string]int64, len(normalizedProviders))
@@ -97,6 +106,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
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 {
seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
)
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
}
func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) 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
}
+6
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -30,6 +31,7 @@ type IAppRepo interface {
BatchCreate(ctx context.Context, apps []model.App) error
Create(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
DeleteByIDs(ctx context.Context, ids []uint) error
DeleteBy(opts ...DBOption) error
@@ -41,6 +43,10 @@ func NewIAppRepo() IAppRepo {
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 {
return func(g *gorm.DB) *gorm.DB {
if len(name) == 0 {
+16
View File
@@ -17,6 +17,8 @@ type IAppDetailRepo interface {
WithIgnored() DBOption
GetFirst(opts ...DBOption) (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
DeleteByAppIds(ctx context.Context, appIds []uint) error
DeleteByIDs(ctx context.Context, appIds []uint) error
@@ -57,6 +59,20 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) 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 {
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
}
+13
View File
@@ -31,6 +31,7 @@ type IAppInstallRepo interface {
GetFirst(opts ...DBOption) (model.AppInstall, error)
Create(ctx context.Context, install *model.AppInstall) error
Save(ctx context.Context, install *model.AppInstall) error
UpdateStatus(install *model.AppInstall) error
DeleteBy(opts ...DBOption) error
Delete(ctx context.Context, install model.AppInstall) error
Page(page, size int, opts ...DBOption) (int64, []model.AppInstall, error)
@@ -38,12 +39,18 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
}
func NewIAppInstallRepo() IAppInstallRepo {
return &AppInstallRepo{}
}
func (a *AppInstallRepo) UpdateStatus(install *model.AppInstall) error {
return getDb().Model(&model.AppInstall{}).Where("id = ? AND updated_at = ?", install.ID, install.UpdatedAt).
Updates(map[string]interface{}{"status": install.Status, "message": install.Message}).Error
}
func (a *AppInstallRepo) WithDetailIdsIn(detailIds []uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("app_detail_id in (?)", detailIds)
@@ -131,6 +138,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
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 {
db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error
+70 -4
View File
@@ -3,6 +3,7 @@ package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type LauncherRepo struct{}
@@ -17,6 +18,11 @@ type ILauncherRepo interface {
GetQuickJump(opts ...DBOption) (model.QuickJump, error)
ListQuickJump(withAll bool) []model.QuickJump
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 {
@@ -80,10 +86,10 @@ func (u *LauncherRepo) ListQuickJump(withAll bool) []model.QuickJump {
}
if !withAll && len(quicks) == 0 {
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: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
{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"},
}
}
@@ -95,13 +101,73 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
for _, item := range quicks {
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"is_show": item.IsShow,
"detail": item.Detail,
"alias": item.Alias,
}).Error; err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
return tx.Commit().Error
}
// 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
}
+29
View File
@@ -6,6 +6,7 @@ import (
"strings"
"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/encrypt"
"gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) 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
WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
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) {
var database model.Database
db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []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
Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
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 {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
}
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
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) {
var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) 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 {
db := global.DB
for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) 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 {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
+60
View File
@@ -0,0 +1,60 @@
package repo
import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type FeaturedModelRepo struct{}
type IFeaturedModelRepo interface {
Page(page, size int, family, info string) (int64, []model.FeaturedModel, error)
Families() ([]string, error)
SyncAll(models []model.FeaturedModel, version string) error
}
func NewIFeaturedModelRepo() IFeaturedModelRepo {
return &FeaturedModelRepo{}
}
func (r *FeaturedModelRepo) Page(page, size int, family, info string) (int64, []model.FeaturedModel, error) {
var total int64
var items []model.FeaturedModel
db := global.DB.Model(&model.FeaturedModel{})
if family != "" {
db = db.Where("family = ?", family)
}
if info != "" {
db = db.Where("LOWER(repo_id) LIKE ?", "%"+strings.ToLower(info)+"%")
}
if err := db.Count(&total).Error; err != nil {
return 0, nil, err
}
if err := db.Order("sort asc").Order("id asc").Limit(size).Offset(size * (page - 1)).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
func (r *FeaturedModelRepo) Families() ([]string, error) {
families := make([]string, 0)
err := global.DB.Model(&model.FeaturedModel{}).Distinct("family").Order("family asc").Pluck("family", &families).Error
return families, err
}
func (r *FeaturedModelRepo) SyncAll(models []model.FeaturedModel, version string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&model.FeaturedModel{}).Error; err != nil {
return err
}
if err := tx.Create(&models).Error; err != nil {
return err
}
return tx.Where("key = ?", "FeaturedModelsVersion").
Assign(model.Setting{Value: version}).
FirstOrCreate(&model.Setting{Key: "FeaturedModelsVersion"}).Error
})
}
+126
View File
@@ -0,0 +1,126 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type ModelDownloadTaskRepo struct{}
type IModelDownloadTaskRepo interface {
Page(page, size int, info, status string, opts ...DBOption) (int64, []model.ModelDownloadTask, error)
GetFirst(opts ...DBOption) (model.ModelDownloadTask, error)
List(opts ...DBOption) ([]model.ModelDownloadTask, error)
Create(task *model.ModelDownloadTask) error
Update(id uint, values map[string]interface{}) error
UpdateBy(values map[string]interface{}, opts ...DBOption) error
DeleteBy(opts ...DBOption) error
WithByDownloadType(downloadType string) DBOption
WithBySource(source string) DBOption
WithByRepoID(repoID string) DBOption
WithByTargetDir(targetDir string) DBOption
WithByStatusIn(status []string) DBOption
}
func NewIModelDownloadTaskRepo() IModelDownloadTaskRepo {
return &ModelDownloadTaskRepo{}
}
func (m *ModelDownloadTaskRepo) WithBySource(source string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("source = ?", source)
}
}
func (m *ModelDownloadTaskRepo) WithByRepoID(repoID string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("repo_id = ?", repoID)
}
}
func (m *ModelDownloadTaskRepo) WithByTargetDir(targetDir string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("target_dir = ?", targetDir)
}
}
func (m *ModelDownloadTaskRepo) WithByStatusIn(status []string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("status in ?", status)
}
}
func (m *ModelDownloadTaskRepo) Page(page, size int, info, status string, opts ...DBOption) (int64, []model.ModelDownloadTask, error) {
var (
total int64
list []model.ModelDownloadTask
)
db := global.DB.Model(&model.ModelDownloadTask{})
for _, opt := range opts {
db = opt(db)
}
if info != "" {
db = db.Where("repo_id LIKE ? OR model_name LIKE ?", "%"+info+"%", "%"+info+"%")
}
if status != "" {
db = db.Where("status = ?", status)
}
if err := db.Count(&total).Error; err != nil {
return 0, nil, err
}
if err := db.Order("created_at desc").Limit(size).Offset(size * (page - 1)).Find(&list).Error; err != nil {
return 0, nil, err
}
return total, list, nil
}
func (m *ModelDownloadTaskRepo) GetFirst(opts ...DBOption) (model.ModelDownloadTask, error) {
var item model.ModelDownloadTask
db := global.DB.Model(&model.ModelDownloadTask{})
for _, opt := range opts {
db = opt(db)
}
err := db.First(&item).Error
return item, err
}
func (m *ModelDownloadTaskRepo) Create(task *model.ModelDownloadTask) error {
return global.DB.Create(task).Error
}
func (m *ModelDownloadTaskRepo) List(opts ...DBOption) ([]model.ModelDownloadTask, error) {
var items []model.ModelDownloadTask
db := global.DB.Model(&model.ModelDownloadTask{})
for _, opt := range opts {
db = opt(db)
}
err := db.Find(&items).Error
return items, err
}
func (m *ModelDownloadTaskRepo) Update(id uint, values map[string]interface{}) error {
return global.DB.Model(&model.ModelDownloadTask{}).Where("id = ?", id).Updates(values).Error
}
func (m *ModelDownloadTaskRepo) UpdateBy(values map[string]interface{}, opts ...DBOption) error {
db := global.DB.Model(&model.ModelDownloadTask{})
for _, opt := range opts {
db = opt(db)
}
return db.Updates(values).Error
}
func (m *ModelDownloadTaskRepo) DeleteBy(opts ...DBOption) error {
db := global.DB.Model(&model.ModelDownloadTask{})
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.ModelDownloadTask{}).Error
}
func (m *ModelDownloadTaskRepo) WithByDownloadType(downloadType string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("download_type = ?", downloadType)
}
}
+19
View File
@@ -15,10 +15,13 @@ type SettingRepo struct{}
type ISettingRepo interface {
GetList(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)
GetCoreValueByKey(key string) (string, error)
Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error
@@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
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) {
var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+26 -6
View File
@@ -17,10 +17,11 @@ type TaskRepo struct {
type ITaskRepo interface {
Save(ctx context.Context, task *model.Task) error
GetFirst(opts ...DBOption) (model.Task, error)
GetExecutingTask(scope string, resourceID uint) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error
CountExecutingTask() (int64, error)
UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error
DeleteAll() error
@@ -29,6 +30,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
}
func NewITaskRepo() ITaskRepo {
@@ -97,6 +99,12 @@ func (t TaskRepo) GetFirst(opts ...DBOption) (model.Task, error) {
return task, nil
}
func (t TaskRepo) GetExecutingTask(scope string, resourceID uint) (model.Task, error) {
var item model.Task
err := getTaskDb(WithByType(scope), t.WithResourceID(resourceID), t.WithByStatus(constant.StatusExecuting)).Order("created_at DESC").Limit(1).Find(&item).Error
return item, err
}
func (t TaskRepo) Page(page, size int, opts ...DBOption) (int64, []model.Task, error) {
var tasks []model.Task
db := getTaskDb(opts...).Model(&model.Task{})
@@ -110,18 +118,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error
}
func (t TaskRepo) UpdateRunningTaskToFailed() error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error
}
func (t TaskRepo) CountExecutingTask() (int64, error) {
func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
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 {
db := global.TaskDB
for _, opt := range opts {
+315
View File
@@ -0,0 +1,315 @@
package repo
import (
"encoding/json"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type VMRepo struct{}
type IVMRepo interface {
Get(opts ...DBOption) (model.VirtualMachine, error)
List() ([]model.VirtualMachine, error)
Count() (int64, error)
Page(page, pageSize int, opts ...DBOption) (int64, []model.VirtualMachine, error)
Create(vm *model.VirtualMachine) error
Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error
WithByNetwork(network string) DBOption
WithByStorage(storage string) DBOption
GetTemplate(opts ...DBOption) (model.VMTemplate, error)
ListTemplates(opts ...DBOption) ([]model.VMTemplate, error)
CreateTemplate(item *model.VMTemplate) error
UpdateTemplateSource(oldName string, newName string) error
DeleteTemplate(opts ...DBOption) error
GetIso(opts ...DBOption) (model.VMIso, error)
PageIso(page, pageSize int, opts ...DBOption) (int64, []model.VMIso, error)
ListIsos(opts ...DBOption) ([]model.VMIso, error)
CreateIso(iso *model.VMIso) error
UpdateIso(id uint, vars map[string]interface{}) error
DeleteIso(opts ...DBOption) error
GetNetwork(opts ...DBOption) (model.VMNetwork, error)
PageNetwork(page, pageSize int, opts ...DBOption) (int64, []model.VMNetwork, error)
ListNetworks(opts ...DBOption) ([]model.VMNetwork, error)
CreateNetwork(network *model.VMNetwork) error
UpdateNetwork(id uint, vars map[string]interface{}) error
DeleteNetwork(opts ...DBOption) error
GetStorage(opts ...DBOption) (model.VMStorage, error)
PageStorage(page, pageSize int, opts ...DBOption) (int64, []model.VMStorage, error)
ListStorages(opts ...DBOption) ([]model.VMStorage, error)
CreateStorage(storage *model.VMStorage) error
UpdateStorage(id uint, vars map[string]interface{}) error
DeleteStorage(opts ...DBOption) error
}
func NewIVMRepo() IVMRepo {
return &VMRepo{}
}
func (v *VMRepo) Get(opts ...DBOption) (model.VirtualMachine, error) {
var vm model.VirtualMachine
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&vm).Error
return vm, err
}
func (v *VMRepo) List() ([]model.VirtualMachine, error) {
var vms []model.VirtualMachine
err := global.DB.Order("created_at desc").Find(&vms).Error
return vms, err
}
func (v *VMRepo) Count() (int64, error) {
var count int64
err := global.DB.Model(&model.VirtualMachine{}).Count(&count).Error
return count, err
}
func (v *VMRepo) Page(page, pageSize int, opts ...DBOption) (int64, []model.VirtualMachine, error) {
var vms []model.VirtualMachine
var total int64
db := global.DB.Model(&model.VirtualMachine{})
for _, opt := range opts {
db = opt(db)
}
err := db.Count(&total).Error
if err != nil {
return 0, nil, err
}
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&vms).Error
return total, vms, err
}
func (v *VMRepo) Create(vm *model.VirtualMachine) error {
return global.DB.Create(vm).Error
}
func (v *VMRepo) Update(id uint, vars map[string]interface{}) error {
if paths, ok := vars["iso_paths"].([]string); ok {
data, err := json.Marshal(paths)
if err != nil {
return err
}
vars["iso_paths"] = string(data)
}
return global.DB.Model(&model.VirtualMachine{}).Where("id = ?", id).Updates(vars).Error
}
func (v *VMRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.VirtualMachine{}).Error
}
func (v *VMRepo) WithByNetwork(network string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("network = ?", network)
}
}
func (v *VMRepo) WithByStorage(storage string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("storage_pool = ?", storage)
}
}
func (v *VMRepo) GetTemplate(opts ...DBOption) (model.VMTemplate, error) {
var item model.VMTemplate
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&item).Error
return item, err
}
func (v *VMRepo) ListTemplates(opts ...DBOption) ([]model.VMTemplate, error) {
var items []model.VMTemplate
db := global.DB.Model(&model.VMTemplate{})
for _, opt := range opts {
db = opt(db)
}
err := db.Order("created_at desc").Order("id desc").Find(&items).Error
return items, err
}
func (v *VMRepo) CreateTemplate(item *model.VMTemplate) error {
return global.DB.Create(item).Error
}
func (v *VMRepo) UpdateTemplateSource(oldName string, newName string) error {
return global.DB.Model(&model.VMTemplate{}).Where("source_vm = ?", oldName).Update("source_vm", newName).Error
}
func (v *VMRepo) DeleteTemplate(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.VMTemplate{}).Error
}
func (v *VMRepo) GetIso(opts ...DBOption) (model.VMIso, error) {
var iso model.VMIso
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&iso).Error
return iso, err
}
func (v *VMRepo) PageIso(page, pageSize int, opts ...DBOption) (int64, []model.VMIso, error) {
var isos []model.VMIso
var total int64
db := global.DB.Model(&model.VMIso{})
for _, opt := range opts {
db = opt(db)
}
err := db.Count(&total).Error
if err != nil {
return 0, nil, err
}
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&isos).Error
return total, isos, err
}
func (v *VMRepo) ListIsos(opts ...DBOption) ([]model.VMIso, error) {
var isos []model.VMIso
db := global.DB.Model(&model.VMIso{})
for _, opt := range opts {
db = opt(db)
}
err := db.Order("created_at desc").Order("id desc").Find(&isos).Error
return isos, err
}
func (v *VMRepo) CreateIso(iso *model.VMIso) error {
return global.DB.Create(iso).Error
}
func (v *VMRepo) UpdateIso(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.VMIso{}).Where("id = ?", id).Updates(vars).Error
}
func (v *VMRepo) DeleteIso(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.VMIso{}).Error
}
func (v *VMRepo) GetNetwork(opts ...DBOption) (model.VMNetwork, error) {
var network model.VMNetwork
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&network).Error
return network, err
}
func (v *VMRepo) PageNetwork(page, pageSize int, opts ...DBOption) (int64, []model.VMNetwork, error) {
var networks []model.VMNetwork
var total int64
db := global.DB.Model(&model.VMNetwork{})
for _, opt := range opts {
db = opt(db)
}
err := db.Count(&total).Error
if err != nil {
return 0, nil, err
}
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&networks).Error
return total, networks, err
}
func (v *VMRepo) ListNetworks(opts ...DBOption) ([]model.VMNetwork, error) {
var networks []model.VMNetwork
db := global.DB.Model(&model.VMNetwork{})
for _, opt := range opts {
db = opt(db)
}
err := db.Order("created_at desc").Order("id desc").Find(&networks).Error
return networks, err
}
func (v *VMRepo) CreateNetwork(network *model.VMNetwork) error {
return global.DB.Create(network).Error
}
func (v *VMRepo) UpdateNetwork(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.VMNetwork{}).Where("id = ?", id).Updates(vars).Error
}
func (v *VMRepo) DeleteNetwork(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.VMNetwork{}).Error
}
func (v *VMRepo) GetStorage(opts ...DBOption) (model.VMStorage, error) {
var storage model.VMStorage
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&storage).Error
return storage, err
}
func (v *VMRepo) PageStorage(page, pageSize int, opts ...DBOption) (int64, []model.VMStorage, error) {
var storages []model.VMStorage
var total int64
db := global.DB.Model(&model.VMStorage{})
for _, opt := range opts {
db = opt(db)
}
err := db.Count(&total).Error
if err != nil {
return 0, nil, err
}
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&storages).Error
return total, storages, err
}
func (v *VMRepo) ListStorages(opts ...DBOption) ([]model.VMStorage, error) {
var storages []model.VMStorage
db := global.DB.Model(&model.VMStorage{})
for _, opt := range opts {
db = opt(db)
}
err := db.Order("created_at desc").Order("id desc").Find(&storages).Error
return storages, err
}
func (v *VMRepo) CreateStorage(storage *model.VMStorage) error {
return global.DB.Create(storage).Error
}
func (v *VMRepo) UpdateStorage(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.VMStorage{}).Where("id = ?", id).Updates(vars).Error
}
func (v *VMRepo) DeleteStorage(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.VMStorage{}).Error
}
+153 -9
View File
@@ -88,6 +88,7 @@ type IAgentService interface {
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
@@ -142,6 +143,9 @@ const (
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
agentType := req.AgentType
if err := validateNativeAgentCreate(req); err != nil {
return nil, err
}
if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err
}
@@ -237,6 +241,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
}
} else if agentType == constant.AppCopaw {
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{}{
@@ -245,6 +251,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
constant.HostIP: "",
}
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 allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
params["ALLOWED_ORIGIN"] = allowedOrigin
@@ -738,7 +750,7 @@ func requiresAgentAccount(agentType string) bool {
}
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
params["PANEL_APP_PORT_HTTPS"] = webUIPort
return
}
@@ -1044,7 +1056,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
if err != nil {
return err
}
@@ -1093,16 +1105,33 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if err != nil {
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
if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel
if _, ok := findAgentAccountModelForProvider(provider, models, account.VerifyModel); ok {
requestedVerifyModel = account.VerifyModel
}
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
}
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
if err != nil {
return err
}
@@ -1116,7 +1145,16 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark
account.Verified = true
if err := global.DB.Save(account).Error; err != nil {
if req.Models != 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
}
terminalai.InvalidateTerminalRuntimeCache()
@@ -1147,13 +1185,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil {
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))
for _, item := range list {
apiKey := ""
if item.RememberAPIKey {
apiKey = item.APIKey
}
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID,
MasterAccountID: item.MasterAccountID,
Provider: item.Provider,
@@ -1239,6 +1298,31 @@ func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) err
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
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
record := &model.AgentAccountModel{
AccountID: account.ID,
@@ -1269,6 +1353,15 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err != nil {
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
for _, item := range models {
if item.RecordID == req.Model.RecordID {
@@ -1307,6 +1400,10 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
}
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))
if err != nil {
return err
@@ -1332,6 +1429,44 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
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 {
return err
}
@@ -1351,13 +1486,18 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
return err
}
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock()
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 {
var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
@@ -1542,6 +1682,10 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err != nil {
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 {
return err
}
+38 -3
View File
@@ -45,9 +45,29 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
if err != nil {
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)
installed, _ := checkPluginInstalled(install.GetPath(), "feishu")
result.Installed = installed
if !legacy {
result = extractBundledFeishuConfig(conf)
}
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
}
@@ -72,6 +92,9 @@ 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 {
config := dto.AgentFeishuConfig{
Enabled: req.Enabled,
@@ -358,6 +381,9 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
if err != nil {
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 {
return err
}
@@ -403,6 +429,9 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
if err != nil {
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 {
return err
}
@@ -443,6 +472,9 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
if err != nil {
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 {
return err
}
@@ -538,10 +570,13 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
}
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
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)
if err != nil {
return nil, err
+677
View File
@@ -0,0 +1,677 @@
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"))
}
+201
View File
@@ -0,0 +1,201 @@
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
}
+6 -2
View File
@@ -8,11 +8,15 @@ import (
)
const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login
try:
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
creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds:
raise SystemExit(1)
+300
View File
@@ -0,0 +1,300 @@
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")
}
+115
View File
@@ -0,0 +1,115 @@
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)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
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
}
+6
View File
@@ -107,6 +107,9 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
if err != nil {
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 {
return err
}
@@ -132,6 +135,9 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if err != nil {
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 {
return err
}
+36 -5
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err
}
modelID = strings.TrimSpace(modelID)
if modelID == "" {
if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
@@ -148,6 +148,10 @@ func readOpenclawConfig(configPath string) (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)
ensureOpenclawUpdateDefaults(conf)
payload, err := json.MarshalIndent(conf, "", " ")
@@ -415,6 +419,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
} else {
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.Status = appInstall.Status
item.Message = appInstall.Message
@@ -442,7 +455,8 @@ func localizedAgentProviderName(provider string) string {
}
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 {
@@ -1253,7 +1267,12 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
if err != nil {
return "", modelEntry{}, "", modelProvider{}, err
}
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{
entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
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,
BaseUrl: providerPatch.BaseURL,
Api: providerPatch.APIType,
@@ -1309,7 +1328,7 @@ func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto
if account == nil {
return nil, fmt.Errorf("account is required")
}
if account.Provider != "custom" && requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 {
if (account.Provider == "vllm" || account.Provider == "llmman") && len(requested) > 1 {
return nil, buserr.New("ErrAgentAccountSingleInitialModel")
}
if len(requested) > 0 {
@@ -1498,6 +1517,14 @@ func sameProviderModelID(provider, left, right string) bool {
}
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
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
}
@@ -1641,6 +1668,10 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw:
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:
return "", "", false
}
+123 -57
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path"
"strings"
"sync"
"syscall"
"time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error
Close(name string) error
Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error)
SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
@@ -46,6 +49,7 @@ func NewIAIToolService() IAIToolService {
}
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption
if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != 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))
if len(taskModel.ID) != 0 {
item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
}
func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -106,6 +117,7 @@ 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)
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)
return err
}
@@ -115,12 +127,13 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(info.Name, containerName)
if len(itemSize) != 0 {
_ = 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()})
if err != nil {
return err
}
return nil
return aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
return updateOllamaModelPool(info.Name, false)
}, nil)
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
@@ -144,6 +157,8 @@ func (u *AIToolService) Close(name string) error {
}
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -151,6 +166,9 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if modelInfo.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
if modelInfo.Status == constant.StatusWaiting {
return buserr.New("TaskIsExecuting")
}
containerName, err := LoadContainerName()
if err != nil {
return err
@@ -160,6 +178,7 @@ 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)
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)
return err
}
@@ -169,12 +188,13 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(modelInfo.Name, containerName)
if len(itemSize) != 0 {
_ = 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()})
if err != nil {
return err
}
return nil
return aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
return updateOllamaModelPool(modelInfo.Name, false)
}, nil)
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
@@ -184,6 +204,8 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}
func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound")
@@ -198,59 +220,37 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
return fmt.Errorf("handle ollama rm %s failed, %v", item.Name, err)
}
}
_ = aiRepo.Delete(repo.WithByID(item.ID))
if err := aiRepo.Delete(repo.WithByID(item.ID)); err != nil {
return err
}
if err := updateOllamaModelPool(item.Name, true); err != nil {
return err
}
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem)
}
return nil
}
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
containerName, err := LoadContainerName()
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return nil, err
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if install.App.Key != "ollama" {
return buserr.New("ErrInvalidParams")
}
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
if err != nil {
return nil, err
return err
}
var list []model.OllamaModel
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]})
if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
return err
}
listInDB, _ := aiRepo.List()
var dropList []dto.OllamaModelDropList
for _, itemModel := range listInDB {
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
return appInstallRepo.BatchUpdateBy(map[string]interface{}{
"model_account_base_url_type": req.ModelAccountBaseURLType,
"model_account_base_url": baseURL,
}, repo.WithByID(install.ID))
}
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +370,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
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) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil {
@@ -387,12 +453,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err
}
lines := strings.Split(stdout, "\n")
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
for _, line := range lines {
if !strings.Contains(line, name) {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
if len(parts) < 5 || parts[0] != name {
continue
}
return parts[2] + " " + parts[3], nil
+209
View File
@@ -0,0 +1,209 @@
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
}
+115 -77
View File
@@ -59,7 +59,8 @@ type IAppService interface {
}
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 {
@@ -134,6 +135,7 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
ID: ap.ID,
Name: ap.Name,
Key: ap.Key,
Icon: appicon.RemoteURL(ap.Icon),
Limit: ap.Limit,
GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend,
@@ -258,26 +260,45 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
if !fileOp.Stat(versionPath) || detail.Update {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err
}
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
}
}
switch app.Type {
case constant.RuntimePHP:
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
}
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err
if app.Resource == constant.AppResourceRemote {
if err = json.Unmarshal([]byte(detail.Params), &paramMap); err != 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, &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap["additionalProperties"]
}
appDetailDTO.Params = paramMap["additionalProperties"]
composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
@@ -286,15 +307,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil {
return appDetailDTO, err
}
composeMap := make(map[string]interface{})
if err := validateAppDetailCompose(compose); err != nil {
return appDetailDTO, err
}
var composeMap struct {
Services map[string]interface{} `yaml:"services"`
}
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
return appDetailDTO, err
}
if service, ok := composeMap["services"]; ok {
servicesMap := service.(map[string]interface{})
for k := range servicesMap {
appDetailDTO.Image = k
}
for k := range composeMap.Services {
appDetailDTO.Image = k
}
}
} else {
@@ -305,23 +328,10 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
appDetailDTO.Params = paramMap
}
if appDetailDTO.DockerCompose == "" {
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)
if err := ensureAppDetailCompose(app, &detail); err != nil {
return appDetailDTO, err
}
appDetailDTO.DockerCompose = detail.DockerCompose
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
@@ -339,6 +349,13 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
if err != nil {
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
paramMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil {
@@ -426,25 +443,13 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
App: app,
}
composeMap := make(map[string]interface{})
var composeRes []byte
if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return
}
} else {
if appDetail.DockerCompose == "" {
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 = ensureAppDetailCompose(app, &appDetail); err != nil {
return
}
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
return
@@ -478,6 +483,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.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
serviceName := ""
for k := range servicesMap {
@@ -504,6 +525,26 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
composeByte []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)
if err != nil {
@@ -577,7 +618,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
}
}
if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil {
if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err
}
if err = handleOpenrestyFile(appInstall); err != nil {
@@ -588,6 +629,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil
}
@@ -597,7 +642,11 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
_ = appInstallRepo.Save(context.Background(), appInstall)
}
installTask.AddSubTaskWithOps(task.GetTaskName(appInstall.Name, task.TaskInstall, task.TaskScopeApp), installApp, handleAppStatus, 0, time.Hour)
installTimeout := time.Hour
if app.Key == "ollama" || app.Key == "vllm" {
installTimeout = 0
}
installTask.AddSubTaskWithOps(task.GetTaskName(appInstall.Name, task.TaskInstall, task.TaskScopeApp), installApp, handleAppStatus, 0, installTimeout)
go func() {
if taskErr := installTask.Execute(); taskErr != nil {
@@ -880,22 +929,10 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res.CanUpdate = true
return res, err
}
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
for _, app := range apps {
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 := readAppList()
if err != nil || list.LastModified != lastModified {
list, err = getAppList()
}
list, err := getAppList()
if err != nil {
return res, err
}
@@ -926,29 +963,26 @@ func getAppFromRepo(downloadPath string) 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 {
return nil, err
}
return readAppList()
}
func readAppList() (*dto.AppList, error) {
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
file, err := os.Open(listFile)
data, err := os.ReadFile(listFile)
if err != nil {
return nil, err
}
defer file.Close()
if err = json.NewDecoder(file).Decode(list); err != nil {
list := &dto.AppList{}
if err = json.Unmarshal(data, list); err != nil {
return nil, err
}
return list, nil
}
var InitTypes = map[string]struct{}{
"runtime": {},
"php": {},
"node": {},
}
func deleteCustomApp() {
installs, err := appInstallRepo.ListBy(context.Background())
if err != nil {
@@ -1046,6 +1080,10 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
return nil, "", "", err
}
if appicon.IsRemoteURL(app.Icon) {
return nil, app.Icon, "", nil
}
if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName)
+341
View File
@@ -0,0 +1,341 @@
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&region=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&region=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)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
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
}
@@ -0,0 +1,70 @@
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")
}
})
}
})
}
}
+253
View File
@@ -0,0 +1,253 @@
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)
}
}
@@ -0,0 +1,326 @@
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 TestRemovedCurrentComposeDoesNotBlockUpgrade(t *testing.T) {
for _, custom := range []bool{false, true} {
t.Run(fmt.Sprintf("custom=%v", custom), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/demo/1.0/docker-compose.yml" {
w.WriteHeader(http.StatusNotFound)
return
}
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)
if err := db.Model(&details[1]).UpdateColumn("status", constant.AppTakeDown).Error; err != nil {
t.Fatal(err)
}
if custom {
install.DockerCompose += " environment:\n USER_SETTING: preserve\n"
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("removed template must preserve the instance and warn about custom configuration: %+v, %v", config, err)
}
var current model.AppDetail
if err := db.First(&current, details[1].ID).Error; err != nil || current.DockerCompose != "" {
t.Fatalf("instance Compose was cached as the official template: %+v, %v", current, err)
}
noCompose := false
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, LoadCompose: &noCompose})
if err != nil || len(versions) != 2 {
t.Fatalf("removed current version blocked available versions: %+v, %v", versions, err)
}
for _, version := range versions {
previews, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: version.Version})
if err != nil {
t.Fatal(err)
}
for _, preview := range previews {
if preview.Version == version.Version && !strings.Contains(preview.DockerCompose, "demo:"+version.Version) {
t.Fatalf("upgrade did not use the selected new template: %+v", preview)
}
}
}
var stored model.AppInstall
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Version != install.Version || stored.Status != install.Status {
t.Fatalf("upgrade preview modified the running instance: %+v, %v", stored, err)
}
})
}
}
func TestRemovedCurrentComposeRejectsInvalidInstanceCompose(t *testing.T) {
for _, compose := range []string{"", "services: []", "services: ["} {
t.Run(compose, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
db, _, install, _ := composeUpgradeDB(t, server.URL)
if err := db.Model(&install).UpdateColumn("docker_compose", compose).Error; err != nil {
t.Fatal(err)
}
if config, err := NewIAppInstalledService().GetParams(install.ID); err == nil || config != nil {
t.Fatalf("invalid instance Compose was accepted: %+v, %v", config, err)
}
})
}
}
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())
}
}
+265
View File
@@ -0,0 +1,265 @@
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)
}
}
+290 -84
View File
@@ -6,15 +6,12 @@ import (
"fmt"
"maps"
"math"
"net/http"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
@@ -31,7 +28,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"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/joho/godotenv"
"github.com/pkg/errors"
@@ -126,7 +122,10 @@ func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []respo
}
}
installDTOs, _ := handleInstalled(installs, req.Update, req.Sync, req.CheckUpdate)
installDTOs, err := handleInstalled(installs, req.Update, req.Sync, req.CheckUpdate)
if err != nil {
return 0, nil, err
}
if req.Update {
total = int64(len(installDTOs))
}
@@ -159,10 +158,19 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
}
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.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt
res.Status = appInstall.Status
res.TaskID = appInstall.TaskID
res.AppInstallID = appInstall.ID
res.IsExist = true
res.InstallPath = path.Join(global.Dir.AppInstallDir, appInstall.App.Key, appInstall.Name)
@@ -251,12 +259,36 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
if req.Operate != constant.Upgrade {
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
defer appUpgradeLocks.Delete(install.ID)
}
operationTask, err := taskRepo.GetExecutingTask(task.TaskScopeApp, install.ID)
if err != nil {
return err
}
if operationTask.ID != "" && req.Operate != constant.Sync {
return buserr.New("TaskIsExecuting")
}
}
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()) {
return buserr.New("ErrInstallDirNotFound")
}
dockerComposePath := install.GetComposePath()
if req.UseLifecycleScripts && (req.Operate == constant.Start || req.Operate == constant.Stop || req.Operate == constant.Restart) {
return operateAppWithLifecycleScripts(install, req, nil)
if (req.UseLifecycleScripts || install.App.Key == "ollama" || install.App.Key == "vllm") && (req.Operate == constant.Start || req.Operate == constant.Stop || req.Operate == constant.Restart) {
return operateAppTask(install, req, nil)
}
switch req.Operate {
case constant.Rebuild:
@@ -289,21 +321,19 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
TaskID: req.TaskID,
UseLifecycleScripts: req.UseLifecycleScripts,
}
if err = deleteAppInstall(deleteReq); err != nil && !req.ForceDelete {
return err
}
return nil
return deleteAppInstall(deleteReq)
case constant.Sync:
return syncAppInstallStatus(&install, true)
case constant.Upgrade:
upgradeReq := request.AppInstallUpgrade{
InstallID: install.ID,
DetailID: req.DetailId,
Backup: req.Backup,
PullImage: req.PullImage,
DeleteImage: req.DeleteImage,
DockerCompose: req.DockerCompose,
TaskID: req.TaskID,
FeishuTransition: req.FeishuTransition,
InstallID: install.ID,
DetailID: req.DetailId,
Backup: req.Backup,
PullImage: req.PullImage,
DeleteImage: req.DeleteImage,
DockerCompose: req.DockerCompose,
TaskID: req.TaskID,
}
return upgradeInstall(upgradeReq)
case constant.Reload:
@@ -319,15 +349,17 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
}
}
func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppInstalledOperate, onFailure func(error)) error {
taskType := task.TaskUpdate
func operateAppTask(install model.AppInstall, req request.AppInstalledOperate, onFailure func(error)) error {
var taskType string
switch req.Operate {
case constant.Start:
taskType = "Run"
install.Status = constant.StatusStarting
case constant.Restart:
taskType = task.TaskRestart
install.Status = constant.StatusRestarting
case constant.Stop:
taskType = "Stop"
install.Status = constant.StatusWaiting
default:
return errors.New("lifecycle script operation not supported")
@@ -350,8 +382,27 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
func(t *task.Task) error {
if err := runScript(t, &install, operation); err != nil {
return err
if req.UseLifecycleScripts {
if err := runScript(t, &install, operation); err != nil {
return err
}
} else {
var out string
var err error
switch req.Operate {
case constant.Start:
out, err = compose.Up(install.GetComposePath())
case constant.Stop:
out, err = compose.Stop(install.GetComposePath())
case constant.Restart:
out, err = compose.Restart(install.GetComposePath())
}
if strings.TrimSpace(out) != "" {
t.Log(out)
}
if err != nil {
return err
}
}
if req.Operate == constant.Stop {
install.Status = constant.StatusStopped
@@ -372,18 +423,20 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
},
nil,
0,
time.Hour,
0,
)
go func() {
if taskErr := operationTask.Execute(); taskErr != nil {
if onFailure != nil {
onFailure(taskErr)
return
}
install.Status = constant.StatusUpErr
install.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &install)
taskErr := operationTask.Execute()
if taskErr == nil {
return
}
if onFailure != nil {
onFailure(taskErr)
return
}
install.Status = constant.StatusUpErr
install.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &install)
}()
return nil
}
@@ -414,6 +467,19 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if err != nil {
return err
}
if installed.App.Key == "ollama" || installed.App.Key == "vllm" {
if _, loaded := appUpgradeLocks.LoadOrStore(installed.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
defer appUpgradeLocks.Delete(installed.ID)
operationTask, err := taskRepo.GetExecutingTask(task.TaskScopeApp, installed.ID)
if err != nil {
return err
}
if operationTask.ID != "" {
return buserr.New("TaskIsExecuting")
}
}
oldInstalled := installed
port, ok := req.Params["PANEL_APP_PORT_HTTP"]
if ok {
@@ -438,10 +504,37 @@ 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
if req.Advanced {
updateGPU := installed.App.Key == "ollama" && req.GPU != nil
if req.Advanced || updateGPU {
composeMap := make(map[string]interface{})
if req.EditCompose {
if req.Advanced && req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return err
}
@@ -450,16 +543,35 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err
}
}
if !req.SkipComposeCommonConfig {
if req.Advanced && !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
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)
if err != nil {
return err
}
installed.DockerCompose = string(composeByte)
}
if req.Advanced {
if req.ContainerName == "" {
req.Params[constant.ContainerName] = installed.ContainerName
} else {
@@ -507,7 +619,7 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
_ = appInstallRepo.Save(context.Background(), &failed)
}
if req.UseLifecycleScripts {
err = operateAppWithLifecycleScripts(installed, request.AppInstalledOperate{
err = operateAppTask(installed, request.AppInstalledOperate{
InstallId: installed.ID,
Operate: constant.Restart,
TaskID: req.TaskID,
@@ -520,10 +632,6 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
restoreConfig(err)
return err
}
if !req.UseLifecycleScripts {
installed.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &installed)
}
proxyChanged := hasAppInstallProxyPassChanged(&oldInstalled, &installed)
currentProxy, currentProxyErr := getAppInstallProxyPass(&installed)
@@ -563,6 +671,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
}
if installed.App.Key == "ollama" && installed.SyncModelAccount {
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
}
return nil
}
@@ -679,6 +790,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if err != nil {
return versions, err
}
var feishuLegacy, feishuConfigured, feishuChecked bool
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
if err != nil {
return versions, err
@@ -703,26 +815,17 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
}
if canUpgrade {
var newCompose string
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable")
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked && (req.LoadCompose == nil || *req.LoadCompose) {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil {
return versions, readErr
}
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return versions, err
}
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
feishuChecked = true
}
if app.Key == constant.AppMysql {
majorVersion := getMajorVersion(install.Version)
@@ -731,15 +834,50 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
}
}
versions = append(versions, dto.AppVersion{
Version: detail.Version,
DetailId: detail.ID,
DockerCompose: newCompose,
FeishuLegacy: feishuTarget && feishuLegacy,
FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version,
DetailId: detail.ID,
})
}
}
sort.Slice(versions, func(i, j int) bool {
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
}
@@ -928,6 +1066,28 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose
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 == "" {
config.ContainerName = install.ContainerName
}
@@ -936,41 +1096,87 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
res.WebUI = install.WebUI
res.Type = install.App.Type
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
res.RawCompose = rawCompose
rawCompose := install.DockerCompose
if install.App.Key != vllmAppKeyForUpgrade {
if err := ensureAppDetailCompose(install.App, &detail); err != nil {
var businessErr buserr.BusinessError
if !errors.As(err, &businessErr) || businessErr.Msg != "ErrAppVersionUnavailable" {
return nil, err
}
if err := validateAppDetailCompose([]byte(install.DockerCompose)); err != nil {
return nil, err
}
// A removed template cannot prove the instance is unmodified.
res.RawCompose = install.DockerCompose
res.IsEdit = true
return &res, nil
}
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
}
func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
operation := ""
switch appInstall.Status {
case constant.StatusInstalling:
operation = task.TaskInstall
case constant.StatusUpgrading:
operation = task.TaskUpgrade
case constant.StatusRebuilding:
operation = task.TaskBuild
case constant.StatusUninstalling:
operation = task.TaskUninstall
case constant.StatusStarting, constant.StatusWaiting:
operation = task.TaskUpdate
case constant.StatusRestarting:
operation = task.TaskRestart
}
if operation != "" {
if err := reconcileAppInstallTaskFailure(appInstall, operation, global.DB, global.TaskDB); err != nil {
if appInstall.App.Key == "ollama" || appInstall.App.Key == "vllm" {
operationTask, err := taskRepo.GetExecutingTask(task.TaskScopeApp, appInstall.ID)
if err != nil {
return err
}
}
switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force {
appInstall.TaskID = operationTask.ID
if operationTask.ID != "" {
appInstall.Status = localEngineTaskStatus(operationTask.Operate, appInstall.Status)
return nil
}
force = true
} else {
operation := ""
switch appInstall.Status {
case constant.StatusInstalling:
operation = task.TaskInstall
case constant.StatusUpgrading:
operation = task.TaskUpgrade
case constant.StatusRebuilding:
operation = task.TaskBuild
case constant.StatusUninstalling:
operation = task.TaskUninstall
case constant.StatusStarting:
operation = "Run"
case constant.StatusWaiting:
operation = "Stop"
case constant.StatusRestarting:
operation = task.TaskRestart
}
if operation != "" {
if err := reconcileAppInstallTaskFailure(appInstall, operation, global.DB, global.TaskDB); err != nil {
return err
}
}
switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusWaiting:
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:
if !force {
return nil
}
}
}
cli, err := docker.NewClient()
if err != nil {
+19
View File
@@ -0,0 +1,19 @@
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)
}
+27
View File
@@ -4,6 +4,7 @@ import (
"errors"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
@@ -74,3 +75,29 @@ func reconcileAppInstallTaskFailure(install *model.AppInstall, operation string,
}
return nil
}
func localEngineTaskStatus(operation, status string) string {
switch operation {
case task.TaskInstall, task.TaskCreate:
return constant.StatusInstalling
case task.TaskUpgrade:
return constant.StatusUpgrading
case task.TaskUninstall, task.TaskDelete:
return constant.StatusUninstalling
case task.TaskBuild:
return constant.StatusRebuilding
case task.TaskRestart:
return constant.StatusRestarting
case "Run":
return constant.StatusStarting
case "Stop":
return constant.StatusWaiting
case task.TaskUpdate:
if status == constant.StatusWaiting {
return constant.StatusWaiting
}
return constant.StatusStarting
default:
return constant.StatusWaiting
}
}
+49
View File
@@ -0,0 +1,49 @@
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)
})
}
+128
View File
@@ -0,0 +1,128 @@
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())
}
})
}
}
+48 -210
View File
@@ -4,12 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
@@ -17,15 +12,11 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"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/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
type appSyncContext struct {
task *task.Task
httpClient http.Client
baseRemoteUrl string
systemVersion string
appsMap map[string]model.App
@@ -34,7 +25,6 @@ type appSyncContext struct {
oldAppIds []uint
appTags []*model.AppTag
skipMetaSync bool
pendingIcons map[string]string
}
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
@@ -51,6 +41,10 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
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")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true}
@@ -82,13 +76,11 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx := &appSyncContext{
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),
systemVersion: setting.SystemVersion,
settingService: settingService,
list: list,
appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
}
if err = SyncTags(list.Extra); err != nil {
@@ -107,7 +99,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
}
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
var addCount, updateCount, deleteCount int
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
@@ -129,40 +121,39 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
}
}
type appWorkItem struct {
appDef dto.AppDefine
app model.App
iconUrl string
hadIcon bool
}
type appWorkResult struct {
appKey string
app model.App
iconStatus int
hadIcon bool
httpFailed bool
}
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
app := item.app
l := item.appDef
result := appWorkResult{
appKey: l.AppProperty.Key,
hadIcon: item.hadIcon,
func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
if list == nil {
return nil
}
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
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 {
appDef dto.AppDefine
app model.App
}
func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
app := item.app
l := item.appDef
app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 {
@@ -189,20 +180,12 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
continue
}
if _, ok := InitTypes[app.Type]; ok {
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 {
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
detail.DockerCompose = ""
}
detail.Params = string(paramByte)
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
detail.DownloadUrl = downloadURL
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true
detail.LastModified = v.LastModified
@@ -215,170 +198,25 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
}
app.Details = newDetails
result.app = app
return result
return app
}
func (c *appSyncContext) syncAppIconsAndDetails() error {
func (c *appSyncContext) syncAppDetails() error {
total := len(c.list.Apps)
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
workItems := make([]appWorkItem, 0, total)
for _, l := range c.list.Apps {
app, ok := c.appsMap[l.AppProperty.Key]
if !ok {
continue
for i, definition := range c.list.Apps {
app, ok := c.appsMap[definition.AppProperty.Key]
if ok {
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
}
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
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++
if (i+1)%max(1, total/4) == 0 || i+1 == total {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, 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)
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total)
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 {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
@@ -407,7 +245,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
ctx.task = t
ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppIconsAndDetails(); err != nil {
if err = ctx.syncAppDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
+82 -11
View File
@@ -60,10 +60,16 @@ type upgradeFileSnapshot struct {
}
type appUpgradeContext struct {
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
openclawOriginal []byte
feishuOriginal []byte
feishuImage string
feishuOldImage string
feishuEnabled bool
feishuLockPath string
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase
stopAttempted bool
@@ -86,12 +92,9 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if err != nil {
return err
}
if install.Status == constant.StatusUpgrading {
if install.Status == constant.StatusUpgrading && install.App.Key != "ollama" && install.App.Key != "vllm" {
return buserr.New("TaskIsExecuting")
}
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
return err
}
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
@@ -101,6 +104,22 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
appUpgradeLocks.Delete(install.ID)
}
}()
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
return err
}
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))
if err != nil {
@@ -129,6 +148,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
return err
}
ctx := &appUpgradeContext{
feishuLockPath: feishuLockPath,
req: req,
original: install,
candidate: install,
@@ -165,7 +185,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if ctx.rollbackErr != nil {
taskErr = fmt.Errorf("%w; %s: %v", taskErr, i18n.GetMsgByKey("UpgradeRollbackFailed"), ctx.rollbackErr)
upgradeTask.Task.ErrorMsg = taskErr.Error()
_ = repo.NewITaskRepo().Update(context.Background(), upgradeTask.Task)
_ = taskRepo.Update(context.Background(), upgradeTask.Task)
}
if !ctx.stopAttempted || ctx.rollbackErr == nil {
restored := ctx.original
@@ -196,8 +216,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err != nil {
return err
}
u.detail.DockerCompose = string(composeContent)
_ = appDetailRepo.Update(context.Background(), u.detail)
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
return err
}
}
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
@@ -222,6 +243,10 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
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 {
for _, relativePath := range []string{
@@ -330,6 +355,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
}
if err = u.prepareFeishuUpgrade(); err != nil {
return err
}
u.phase = appUpgradePrepared
return nil
}
@@ -387,6 +415,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped
if err := u.checkFeishuUpgradeSource(); err != nil {
return err
}
var err error
if u.original.App.Key == constant.AppOpenresty {
@@ -454,6 +485,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else {
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.Message = ""
@@ -579,8 +613,34 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
if u.feishuOriginal != nil {
return 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 != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
@@ -602,6 +662,11 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
}
func (u *appUpgradeContext) finishRollback() error {
if u.feishuOriginal != nil {
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
return err
}
}
restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err
@@ -643,6 +708,12 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
}
func (u *appUpgradeContext) cleanup() {
if u.feishuLockPath != "" {
feishuSwitches.Delete(u.feishuLockPath)
}
if u.feishuOriginal != nil && u.rollbackErr != nil {
return
}
if u.snapshot != nil {
u.snapshot.Cleanup()
}
+336
View File
@@ -0,0 +1,336 @@
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)
}
+161 -49
View File
@@ -8,6 +8,7 @@ import (
"maps"
"math"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -27,6 +28,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"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/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
@@ -390,12 +392,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
}
}
}
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext()
defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil {
return err
}
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) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
@@ -468,7 +485,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir)
}
tx.Commit()
if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 {
@@ -476,7 +498,11 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
}
return nil
}
uninstallTask.AddSubTask(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil)
uninstallTimeout := 30 * time.Minute
if install.App.Key == "ollama" || install.App.Key == "vllm" {
uninstallTimeout = 0
}
uninstallTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil, 0, uninstallTimeout)
go func() {
if err := uninstallTask.Execute(); err != nil {
install.Status = constant.StatusError
@@ -891,16 +917,52 @@ 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) {
if app.IsLocalApp() || app.IsCustomApp() {
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)
appDownloadDir := app.GetAppResourcePath()
appVersionDir := path.Join(appDownloadDir, appDetail.Version)
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) {
return
if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
return nil
}
}
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
if err != nil {
return err
}
if !fileOp.Stat(appDownloadDir) {
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
@@ -909,23 +971,14 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
}
if logger == nil {
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
} else {
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
logger.Printf("download app [%s] from %s", app.Name, downloadURL)
}
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
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 err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
if logger == nil {
global.LOG.Errorf("download app [%s] error %v", app.Name, err)
} else {
@@ -942,8 +995,16 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
return
}
_ = fileOp.DeleteFile(filePath)
appDetail.Update = false
_ = appDetailRepo.Update(context.Background(), appDetail)
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err
}
marked, err := appDetailRepo.MarkDownloaded(appDetail)
if err != nil {
return err
}
if !marked {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
return
}
@@ -1169,28 +1230,36 @@ func upApp(task *task.Task, appInstall *model.AppInstall, pullImages, useLifecyc
func rebuildApp(appInstall model.AppInstall) error {
appInstall.Status = constant.StatusRebuilding
_ = appInstallRepo.Save(context.Background(), &appInstall)
go func() {
dockerComposePath := appInstall.GetComposePath()
out, err := compose.Down(dockerComposePath)
if err := appInstallRepo.Save(context.Background(), &appInstall); err != nil {
return err
}
buildTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskBuild, task.TaskScopeApp, "", appInstall.ID)
if err != nil {
return handleErr(appInstall, err, "")
}
buildTask.AddSubTaskWithOps(buildTask.Name, func(t *task.Task) error {
out, err := compose.Down(appInstall.GetComposePath())
t.Log(out)
if err != nil {
_ = handleErr(appInstall, err, out)
return
return handleErr(appInstall, err, out)
}
out, err = compose.Up(appInstall.GetComposePath())
t.Log(out)
if err != nil {
_ = handleErr(appInstall, err, out)
return
return handleErr(appInstall, err, out)
}
containerNames, err := getContainerNames(appInstall)
if err != nil {
_ = handleErr(appInstall, err, out)
return
return handleErr(appInstall, err, out)
}
appInstall.ContainerName = strings.Join(containerNames, ",")
appInstall.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &appInstall)
return appInstallRepo.Save(context.Background(), &appInstall)
}, nil, 0, 0)
go func() {
if err := buildTask.Execute(); err != nil {
_ = handleErr(appInstall, err, "")
}
}()
return nil
}
@@ -1217,9 +1286,8 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails
}
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
apps := make(map[string]model.App, len(oldApps))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps {
old.Status = constant.AppTakeDown
apps[old.Key] = old
@@ -1260,12 +1328,14 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
app.Architectures = strings.Join(config.Architectures, ",")
app.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport
if item.Icon != "" {
pendingIcons[key] = item.Icon
if appicon.IsRemoteURL(item.Icon) {
app.Icon = item.Icon
} else if item.Icon != "" {
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
}
apps[key] = app
}
return apps, pendingIcons
return apps
}
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
@@ -1419,7 +1489,11 @@ func synAppInstall(containers map[string]container.Summary, appInstall *model.Ap
}
appInstall.Status = constant.StatusError
appInstall.Message = buserr.WithName("ErrContainerNotFound", strings.Join(containerNames, ",")).Error()
_ = appInstallRepo.Save(context.Background(), appInstall)
if appInstall.App.Key == "ollama" || appInstall.App.Key == "vllm" {
_ = appInstallRepo.UpdateStatus(appInstall)
} else {
_ = appInstallRepo.Save(context.Background(), appInstall)
}
return
}
notFoundNames := make([]string, 0)
@@ -1478,7 +1552,11 @@ func synAppInstall(containers map[string]container.Summary, appInstall *model.Ap
appInstall.Message = msg
appInstall.Status = constant.StatusUnHealthy
}
_ = appInstallRepo.Save(context.Background(), appInstall)
if appInstall.App.Key == "ollama" || appInstall.App.Key == "vllm" {
_ = appInstallRepo.UpdateStatus(appInstall)
} else {
_ = appInstallRepo.Save(context.Background(), appInstall)
}
}
func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpdate bool) ([]response.AppInstallDTO, error) {
@@ -1486,22 +1564,43 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
res []response.AppInstallDTO
containersMap map[string]container.Summary
)
if sync {
syncContainers := sync
for _, installed := range appInstallList {
if installed.App.Key == "ollama" || installed.App.Key == "vllm" {
syncContainers = true
break
}
}
if syncContainers {
cli, err := docker.NewClient()
if err == nil {
defer cli.Close()
containers, err := cli.ListAllContainers()
if err == nil {
containersMap = make(map[string]container.Summary, len(containers))
for _, contain := range containers {
containersMap[contain.Names[0]] = contain
}
}
if err != nil {
return nil, err
}
defer cli.Close()
containers, err := cli.ListAllContainers()
if err != nil {
return nil, err
}
containersMap = make(map[string]container.Summary, len(containers))
for _, contain := range containers {
containersMap[contain.Names[0]] = contain
}
}
for _, installed := range appInstallList {
if sync && appInstallOperationPending(installed.Status) {
localEngine := installed.App.Key == "ollama" || installed.App.Key == "vllm"
if localEngine {
operationTask, err := taskRepo.GetExecutingTask(task.TaskScopeApp, installed.ID)
if err != nil {
return nil, err
}
installed.TaskID = operationTask.ID
if operationTask.ID != "" {
installed.Status = localEngineTaskStatus(operationTask.Operate, installed.Status)
} else {
synAppInstall(containersMap, &installed, true)
}
} else if sync && appInstallOperationPending(installed.Status) {
if err := syncAppInstallStatus(&installed, false); err != nil {
return nil, err
}
@@ -1510,7 +1609,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
continue
}
if sync && !doNotNeedSync(installed) {
if sync && !localEngine && !doNotNeedSync(installed) {
synAppInstall(containersMap, &installed, false)
}
@@ -1524,10 +1623,12 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
AppDetailID: installed.AppDetailId,
Version: installed.Version,
Status: installed.Status,
TaskID: installed.TaskID,
Message: installed.Message,
HttpPort: installed.HttpPort,
HttpsPort: installed.HttpsPort,
AppName: installed.App.Name,
Icon: appicon.RemoteURL(installed.App.Icon),
AppKey: installed.App.Key,
AppType: installed.App.Type,
Path: installed.GetPath(),
@@ -1982,8 +2083,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
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 {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if managesOpenrestySiteDirectory(app, appDetail) {
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+16 -4
View File
@@ -197,7 +197,8 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
return nil
}
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error {
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
// Validate AI Gateway backups before stopping the app, creating rollback
// backups, or touching its files/database. Keep the validated extraction for
// the restore so uploaded/encrypted archives are not unpacked twice.
@@ -246,12 +247,15 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err
}
defer func() {
defer os.RemoveAll(tmpPath)
if !startAfter {
return
}
if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
_ = os.RemoveAll(tmpPath)
}()
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
@@ -352,8 +356,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
appDir := install.GetPath()
backPath := fmt.Sprintf("%s_bak", appDir)
_ = fileOp.Rename(appDir, backPath)
_ = fileOp.CreateDir(appDir, constant.DirPerm)
if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
return err
}
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
return err
}
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
t.LogStart(deCompressName)
@@ -376,6 +384,10 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
_, _ = file.WriteString(mergedEnvContent)
}
// The upgrade transaction verifies readiness before saving its restored install.
if !startAfter {
return nil
}
backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err
}
nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil {
if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err
}
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+92 -33
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error)
Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error)
LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error)
ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{}
}
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
client, err := docker.NewDockerClient()
if err != nil {
return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters)
}
containers, err := client.ContainerList(context.Background(), options)
containers, err := client.ContainerList(ctx, options)
if err != nil {
return 0, nil, err
}
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
backData = records[start:end]
}
// Batch-load persisted checks; never contact Docker Hub while rendering the list.
ids := make([]string, 0, len(backData))
for _, row := range backData {
ids = append(ids, row.ContainerID)
}
if len(ids) > 0 {
var checks []model.ContainerImageCheckUpdate
if err := global.DB.Where("container_id IN ?", ids).Find(&checks).Error; err != nil {
return 0, nil, err
}
byID := make(map[string]model.ContainerImageCheckUpdate, len(checks))
for _, check := range checks {
byID[check.ContainerID] = check
}
for i := range backData {
check, ok := byID[backData[i].ContainerID]
if ok && sameContainerImage(check.ImageID, backData[i].ImageId) && check.ImageName == backData[i].ImageName {
backData[i].LatestVersion = check.LatestVersion
backData[i].TargetImage = check.TargetImage
backData[i].HasUpdate = check.HasUpdate
}
}
}
for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 {
@@ -206,27 +229,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options
}
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
var data dto.ContainerStatus
client, err := docker.NewDockerClient()
if err != nil {
return data, err
}
defer client.Close()
c := context.Background()
images, _ := client.ImageList(c, image.ListOptions{All: true})
data.ImageCount = len(images)
repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo)
templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{})
data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true})
if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
data.ImageCount = len(images)
repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo)
templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client)
}
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers)
for _, item := range containers {
switch item.State {
@@ -292,25 +318,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
}
return data, nil
}
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) {
func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true})
options := 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 {
return nil, err
}
datas := make([]dto.ContainerListStats, len(list))
jobs := make(chan int)
var wg sync.WaitGroup
wg.Add(len(list))
for i := 0; i < len(list); i++ {
go func(index int, item container.Summary) {
datas[index] = loadCpuAndMem(client, item.ID)
wg.Done()
}(i, list[i])
for worker := 0; worker < min(8, len(list)); worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
datas[index].ContainerID = list[index].ID
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()
return datas, nil
}
@@ -1634,6 +1683,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
}
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 {
var err error
client, err = docker.NewDockerClient()
@@ -1641,7 +1694,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false
}
}
images, err := client.ImageList(context.Background(), image.ListOptions{})
images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil {
return false
}
@@ -1683,6 +1736,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
}
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)
repos, err := imageRepoRepo.List()
if err != nil {
@@ -1690,7 +1747,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
}
imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName)
return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
}
options := image.PullOptions{}
@@ -1703,7 +1760,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err
}
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
}
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
@@ -1722,11 +1779,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected
}
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats {
func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
data := dto.ContainerListStats{
ContainerID: containerItem,
}
res, err := client.ContainerStats(context.Background(), containerItem, false)
res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil {
return data
}
@@ -0,0 +1,402 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"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/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const imageCheckUpdateSetting = "ContainerImageUpdateEnabled"
type ImageCheckUpdateStatus struct {
TaskID string `json:"taskID"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Checked int `json:"checked"`
Updates int `json:"updates"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"`
}
var imageCheckUpdateState struct {
sync.Mutex
status ImageCheckUpdateStatus
}
func GetContainerImageCheckUpdateStatus() (ImageCheckUpdateStatus, error) {
var setting model.Setting
if err := global.DB.Where("key = ?", imageCheckUpdateSetting).First(&setting).Error; err != nil {
return ImageCheckUpdateStatus{}, err
}
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
status := imageCheckUpdateState.status
status.Enabled = setting.Value == "Enable"
return status, nil
}
func SetContainerImageCheckUpdateSchedule(enabled bool) error {
value := "Disable"
if enabled {
_, err := req_helper.LoadRuntimeProxyURL()
if errors.Is(err, req_helper.ErrProxyRequired) {
return buserr.New("ErrImageCheckProxyRequired")
}
if err != nil {
return err
}
value = "Enable"
}
return global.DB.Model(&model.Setting{}).Where("key = ?", imageCheckUpdateSetting).Update("value", value).Error
}
// StartContainerImageCheckUpdate coalesces manual and scheduled runs on this node.
func StartContainerImageCheckUpdate() (string, error) {
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
if imageCheckUpdateState.status.Running {
return imageCheckUpdateState.status.TaskID, nil
}
taskItem, err := task.NewTask(i18n.GetMsgByKey("ContainerImageCheckUpdate"), task.TaskScan, task.TaskScopeImage, "", 0)
if err != nil {
return "", err
}
// Persist before returning the ID so the log drawer can immediately find it.
if err := global.TaskDB.Save(taskItem.Task).Error; err != nil {
_ = taskItem.Logger.Out.(io.Closer).Close()
taskItem.DeleteLogFile()
if cancel, ok := global.LoadTaskCancel(taskItem.TaskID); ok {
cancel()
}
global.RemoveTaskCancel(taskItem.TaskID)
return "", err
}
imageCheckUpdateState.status = ImageCheckUpdateStatus{Running: true, TaskID: taskItem.TaskID}
go func() {
result := ImageCheckUpdateStatus{TaskID: taskItem.TaskID}
defer func() {
result.FinishedAt = time.Now().UnixMilli()
imageCheckUpdateState.Lock()
imageCheckUpdateState.status = result
imageCheckUpdateState.Unlock()
}()
// The action owns its deadline so the task runner waits for cleanup and logs.
taskItem.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerImageCheckUpdate"), func(t *task.Task) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("image update check failed: %v", recovered)
}
t.Log(i18n.GetMsgWithMap("ContainerImageCheckSummary", map[string]interface{}{
"checked": result.Checked, "updates": result.Updates, "failed": result.Failed, "skipped": result.Skipped,
}))
}()
ctx, cancel := context.WithTimeout(t.TaskCtx, 30*time.Minute)
defer cancel()
if err = runContainerImageCheckUpdate(ctx, &result, t.Log); err != nil {
return err
}
if result.Failed > 0 {
return fmt.Errorf("%s", i18n.GetMsgWithMap("ContainerImageCheckFailures", map[string]interface{}{"count": result.Failed}))
}
return nil
}, nil, 0, 0)
if err := taskItem.Execute(); err != nil {
result.Error = err.Error()
global.LOG.Errorf("container image check: %s", err)
}
}()
return taskItem.TaskID, nil
}
// These caches live only for one check. Remote results do not depend on a
// container's local image ID, which is compared separately for every baseline.
type imageCheckUpdateRepository struct {
client imageCheckUpdateHubClient
err error
tags []string
tagsLoaded bool
tagsErr error
}
type imageCheckUpdateTargetKey struct {
repository, tag string
platform dockerhub.Platform
}
type imageCheckUpdateTargetResult struct {
target dockerhub.Target
compatible bool
err error
}
type imageCheckUpdateResult struct {
record model.ContainerImageCheckUpdate
err error
}
type imageCheckUpdateCache struct {
username, password string
httpClient *http.Client
repositories map[string]*imageCheckUpdateRepository
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
}
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
httpClient, err := req_helper.NewRuntimeHTTPClient()
if errors.Is(err, req_helper.ErrProxyRequired) {
return buserr.New("ErrImageCheckProxyRequired")
}
if err != nil {
return err
}
if httpClient.Transport != nil {
defer httpClient.CloseIdleConnections()
}
log := func(key string, values map[string]interface{}) {
if logger != nil {
logger(i18n.GetMsgWithMap(key, values))
}
}
cli, err := newImageCheckUpdateDockerClient()
if err != nil {
return err
}
defer cli.Close()
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return err
}
log("ContainerImageCheckScope", map[string]interface{}{"count": len(containers)})
// Load old results once, rather than issuing one SELECT for every failed check.
var previousRows []model.ContainerImageCheckUpdate
if err := global.DB.WithContext(ctx).Find(&previousRows).Error; err != nil {
return err
}
previous := make(map[string]model.ContainerImageCheckUpdate, len(previousRows))
for _, row := range previousRows {
previous[row.ContainerID] = row
}
cache := imageCheckUpdateCache{httpClient: httpClient, repositories: map[string]*imageCheckUpdateRepository{}, targets: map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult{}}
cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg != nil {
for _, host := range []string{"https://index.docker.io/v1/", "docker.io", "registry-1.docker.io"} {
auth, e := cfg.GetAuthConfig(host)
if e == nil && auth.Username != "" {
cache.username, cache.password = auth.Username, auth.Password
break
}
}
}
checks := map[string]imageCheckUpdateResult{}
ids := make([]string, 0, len(containers))
names := make(map[string]string, len(containers))
pending := make([]model.ContainerImageCheckUpdate, 0, 100)
for _, item := range containers {
if err := ctx.Err(); err != nil {
return err
}
ids = append(ids, item.ID)
name := item.ID
if len(item.Names) > 0 {
name = strings.TrimPrefix(item.Names[0], "/")
}
names[item.ID] = name
log("ContainerImageCheckItem", map[string]interface{}{"name": name, "image": item.Image})
repository, tag, supported := dockerhub.Reference(item.Image)
key := repository + ":" + tag + "@" + item.ImageID
entry, found := checks[key]
if !found {
entry.record, entry.err = checkContainerImageUpdate(ctx, cli, item, repository, tag, supported, &cache)
checks[key] = entry
}
record := entry.record
record.ContainerID, record.ImageName = item.ID, item.Image
if entry.err != nil {
record.CheckStatus, record.CheckError = "failed", entry.err.Error()
old := previous[item.ID]
if old.ImageID == item.ImageID && old.ImageName == item.Image {
record.LatestVersion, record.TargetImage, record.TargetImageID, record.HasUpdate = old.LatestVersion, old.TargetImage, old.TargetImageID, old.HasUpdate
}
}
pending = append(pending, record)
if len(pending) == 100 {
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
pending = pending[:0]
}
}
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
query := global.DB.WithContext(ctx).Where("1 = 1")
if len(ids) > 0 {
query = query.Where("container_id NOT IN ?", ids)
}
return query.Delete(&model.ContainerImageCheckUpdate{}).Error
}
func checkContainerImageUpdate(ctx context.Context, cli imageCheckUpdateDockerClient, item container.Summary, repository, tag string, supported bool, cache *imageCheckUpdateCache) (model.ContainerImageCheckUpdate, error) {
record := model.ContainerImageCheckUpdate{ImageID: item.ImageID, ImageName: item.Image, CheckStatus: "success", CheckedAt: time.Now()}
if !supported {
record.CheckStatus = "skipped"
return record, nil
}
inspected, err := cli.ImageInspect(ctx, item.ImageID)
if err != nil {
return record, err
}
repo, loaded := cache.repositories[repository]
if !loaded {
repo = &imageCheckUpdateRepository{}
repo.client, repo.err = newImageCheckUpdateHubClient(ctx, repository, cache.username, cache.password, cache.httpClient)
cache.repositories[repository] = repo
}
if repo.err != nil {
return record, repo.err
}
if dockerhub.NeedsTags(tag) {
if !repo.tagsLoaded {
repo.tags, repo.tagsErr = repo.client.Tags(ctx)
repo.tagsLoaded = true
}
if repo.tagsErr != nil {
return record, repo.tagsErr
}
}
platform := dockerhub.Platform{OS: inspected.Os, Architecture: inspected.Architecture, Variant: inspected.Variant}
for _, candidate := range dockerhub.Candidates(tag, repo.tags) {
key := imageCheckUpdateTargetKey{repository: repository, tag: candidate, platform: platform}
remote, loaded := cache.targets[key]
if !loaded {
remote.target, remote.compatible, remote.err = repo.client.Resolve(ctx, candidate, platform)
cache.targets[key] = remote
}
if remote.err != nil {
return record, remote.err
}
if !remote.compatible {
continue
}
record.LatestVersion = remote.target.Tag
record.TargetImage = "docker.io/" + repository + ":" + remote.target.Tag
record.TargetImageID = remote.target.ConfigDigest
// Both versioned and floating tags can be republished with new image content.
record.HasUpdate = remote.target.Tag != tag || remote.target.ConfigDigest != inspected.ID
return record, nil
}
return record, fmt.Errorf("no compatible image for %s/%s", platform.OS, platform.Architecture)
}
// Revalidate immediately before each small write batch. Never hold a database
// transaction open while waiting for Docker or Docker Hub.
func saveContainerImageCheckUpdates(ctx context.Context, cli imageCheckUpdateDockerClient, pending []model.ContainerImageCheckUpdate, names map[string]string, result *ImageCheckUpdateStatus, log func(string, map[string]interface{})) error {
records := make([]model.ContainerImageCheckUpdate, 0, len(pending))
for _, record := range pending {
current, err := cli.ContainerInspect(ctx, record.ContainerID)
if err != nil && !errdefs.IsNotFound(err) {
return err
}
if err != nil || current.Image != record.ImageID {
result.Skipped++
log("ContainerImageCheckChanged", map[string]interface{}{"name": names[record.ContainerID]})
continue
}
records = append(records, record)
}
if len(records) == 0 {
return nil
}
if err := global.DB.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).CreateInBatches(&records, 100).Error; err != nil {
return err
}
// Count and announce only results that were successfully persisted.
for _, record := range records {
values := map[string]interface{}{"name": names[record.ContainerID], "image": record.ImageName}
switch record.CheckStatus {
case "failed":
result.Failed++
values["error"] = record.CheckError
log("ContainerImageCheckFailed", values)
case "skipped":
result.Skipped++
log("ContainerImageCheckSkipped", values)
default:
result.Checked++
if record.HasUpdate {
result.Updates++
values["target"] = record.TargetImage
log("ContainerImageCheckAvailable", values)
} else {
log("ContainerImageCheckCurrent", values)
}
}
}
return nil
}
// sameContainerImage guards cached results after external image changes.
func sameContainerImage(stored, displayed string) bool {
return strings.TrimPrefix(stored, "sha256:") == strings.TrimPrefix(displayed, "sha256:")
}
// Reconcile only after Docker confirms the replacement container uses the target.
func reconcileContainerImageCheckUpdate(oldID, newID, imageID, imageName string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
var previous model.ContainerImageCheckUpdate
if err := tx.Where("container_id = ?", oldID).Find(&previous).Error; err != nil {
return err
}
if previous.ContainerID == "" {
return nil
}
if err := tx.Where("container_id = ?", oldID).Delete(&model.ContainerImageCheckUpdate{}).Error; err != nil {
return err
}
if previous.TargetImageID != imageID {
return nil
}
previous.ContainerID = newID
previous.ImageID = imageID
previous.ImageName = imageName
previous.HasUpdate = false
previous.CheckStatus = "success"
previous.CheckError = ""
previous.CheckedAt = time.Now()
return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&previous).Error
})
}
type imageCheckUpdateDockerClient interface {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
Close() error
}
type imageCheckUpdateHubClient interface {
Tags(context.Context) ([]string, error)
Resolve(context.Context, string, dockerhub.Platform) (dockerhub.Target, bool, error)
}
var newImageCheckUpdateDockerClient = func() (imageCheckUpdateDockerClient, error) { return docker.NewDockerClient() }
var newImageCheckUpdateHubClient = func(ctx context.Context, repository, username, password string, httpClient *http.Client) (imageCheckUpdateHubClient, error) {
return dockerhub.New(ctx, repository, username, password, httpClient)
}
+273 -70
View File
@@ -91,81 +91,100 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
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 {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil {
_ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
defer client.Close()
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
return fmt.Errorf("pull image %s failed: %w", req.Image, err)
}
}
return nil
}, 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 updated, inspectErr := client.ContainerInspect(ctx, item); inspectErr == nil {
if cacheErr := reconcileContainerImageCheckUpdate(oldContainer.ID, updated.ID, updated.Image, updated.Config.Image); cacheErr != nil {
global.LOG.Warnf("refresh container image update state: %v", cacheErr)
}
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
}, 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))
unlock := containerOperationLock.lock(item)
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
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())
}
}()
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
// Stage contexts own the deadlines, including a separate recovery budget.
for _, subTask := range taskItem.SubTasks {
subTask.Timeout = 0
}
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
return err
}
return nil
}
@@ -192,11 +211,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct {
mutex sync.Mutex
token chan struct{}
references int
}
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))
for _, name := range names {
if name != "" {
@@ -209,7 +233,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
sort.Strings(orderedNames)
if len(orderedNames) == 0 {
return func() {}
return func() {}, nil
}
l.mutex.Lock()
@@ -220,7 +244,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames {
entry := l.locks[name]
if entry == nil {
entry = &containerOperationLockEntry{}
entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry
}
entry.references++
@@ -228,12 +252,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
l.mutex.Unlock()
for _, entry := range entries {
entry.mutex.Lock()
}
return func() {
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
acquired := 0
release := func() {
for index := acquired - 1; index >= 0; index-- {
<-entries[index].token
}
l.mutex.Lock()
defer l.mutex.Unlock()
@@ -245,6 +267,20 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
}
}
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 {
@@ -272,6 +308,130 @@ func switchContainer(
createNew func() (container.CreateResponse, error),
tty bool,
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) {
if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty")
@@ -281,9 +441,32 @@ func switchContainer(
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 err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
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)
if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -298,6 +481,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -309,6 +495,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr)
}
@@ -316,11 +505,17 @@ func switchContainer(
created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr)
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
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)
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)
@@ -329,6 +524,9 @@ func switchContainer(
if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
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)
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)
@@ -336,6 +534,11 @@ func switchContainer(
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})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil

Some files were not shown because too many files have changed in this diff Show More