Compare commits

..
149 changed files with 3046 additions and 8080 deletions
+2 -9
View File
@@ -207,7 +207,6 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
// @Accept json // @Accept json
// @Param key path string true "app key" // @Param key path string true "app key"
// @Success 200 {file} file "app icon" // @Success 200 {file} file "app icon"
// @Success 302 "redirect to the official icon URL"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /apps/icon/:key [get] // @Router /apps/icon/:key [get]
@@ -217,18 +216,12 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
iconBytes, source, etag, err := appService.GetAppIcon(appKey) iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
if appicon.IsRemoteURL(source) {
c.Header("Cache-Control", "no-cache")
c.Redirect(http.StatusFound, source)
return
}
if len(iconBytes) == 0 { if len(iconBytes) == 0 {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
return return
@@ -236,7 +229,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=2592000") c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" && source != "" { if etag != "" && filename != "" {
c.Header("ETag", etag) c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag { if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified) c.Status(http.StatusNotModified)
-109
View File
@@ -1,109 +0,0 @@
package v2
import (
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestAppIconEndpointPreservesRemoteAndHistoricalSources(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "icons.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}); err != nil {
t.Fatal(err)
}
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
global.DB = db
global.Dir.IconCacheDir = t.TempDir()
global.LOG = logrus.New()
global.LOG.SetOutput(io.Discard)
t.Cleanup(func() {
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
sqlDB, _ := db.DB()
_ = sqlDB.Close()
})
var imageRequests atomic.Int32
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imageRequests.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(cdn.Close)
remoteURL := cdn.URL + "/icon.png?token=a%2Bb&v=42#image"
image := "\x89PNG\r\n\x1a\noriginal-icon"
fileName := "app_php.png"
if err := os.WriteFile(filepath.Join(global.Dir.IconCacheDir, fileName), []byte(image), 0644); err != nil {
t.Fatal(err)
}
app := model.App{Key: "php", Resource: constant.AppResourceRemote}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/v2/apps/icon/:key", (&BaseApi{}).GetAppIcon)
for _, tc := range []struct {
name string
icon string
ifNoneMatch string
status int
body string
etag string
}{
{"remote URL", remoteURL, "", http.StatusFound, "", ""},
{"remote URL ignores legacy etag", remoteURL, `"old"`, http.StatusFound, "", ""},
{"historical file", appicon.BuildIconField(fileName, `"42"`), "", http.StatusOK, image, `"42"`},
{"historical file cached", appicon.BuildIconField(fileName, `"42"`), `"42"`, http.StatusNotModified, "", `"42"`},
{"historical file changed", appicon.BuildIconField(fileName, `"42"`), `"old"`, http.StatusOK, image, `"42"`},
{"historical base64", base64.StdEncoding.EncodeToString([]byte(image)), "", http.StatusOK, image, ""},
{"missing file", "app_missing.png?etag=old", "", http.StatusNoContent, "", ""},
{"invalid base64", "not-an-icon", "", http.StatusNoContent, "", ""},
{"invalid URL", "javascript:alert(1)", "", http.StatusNoContent, "", ""},
{"empty icon", "", "", http.StatusNoContent, "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if err := db.Model(&app).UpdateColumn("icon", tc.icon).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php?operateNode=child", nil)
req.Header.Set("If-None-Match", tc.ifNoneMatch)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("status = %d, want %d; body = %s", res.Code, tc.status, res.Body.String())
}
if res.Header().Get("ETag") != tc.etag {
t.Fatalf("etag = %q, want %q", res.Header().Get("ETag"), tc.etag)
}
if tc.status == http.StatusFound {
if res.Header().Get("Location") != remoteURL || res.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("redirect URL or cache policy was changed: %v", res.Header())
}
} else if res.Body.String() != tc.body {
t.Fatalf("body = %q, want %q", res.Body.String(), tc.body)
}
if imageRequests.Load() != 0 {
t.Fatalf("icon endpoint fetched the image %d times", imageRequests.Load())
}
})
}
}
-1
View File
@@ -203,7 +203,6 @@ func (b *BaseApi) GetServices(c *gin.Context) {
// @Tags App // @Tags App
// @Summary Search app update version by install id // @Summary Search app update version by install id
// @Accept json // @Accept json
// @Param request body request.AppUpdateVersion true "request"
// @Success 200 {array} dto.AppVersion // @Success 200 {array} dto.AppVersion
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
@@ -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)
}
-5
View File
@@ -3,16 +3,11 @@ package v2
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator" "github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
helper.SuccessWithData(c, service.LoadModelGPUDevices())
}
// @Tags AI // @Tags AI
// @Summary Load gpu / xpu info // @Summary Load gpu / xpu info
// @Accept json // @Accept json
-22
View File
@@ -2,28 +2,6 @@ package dto
import "time" import "time"
type ModelGPUDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
UUID string `json:"uuid,omitempty"`
Available bool `json:"available"`
}
type ModelGPUDevices struct {
Devices []ModelGPUDevice `json:"devices"`
Warnings []string `json:"warnings"`
}
type ModelGPUConfig struct {
Mode string `json:"mode"`
Count int `json:"count,omitempty"`
Error string `json:"error,omitempty"`
Enabled bool `json:"enabled"`
DeviceIDs []string `json:"deviceIDs"`
Capabilities []string `json:"capabilities"`
}
type OllamaModelInfo struct { type OllamaModelInfo struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
+3
View File
@@ -21,6 +21,9 @@ type InspectReq struct {
} }
type ContainerInfo struct { type ContainerInfo struct {
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"` ContainerID string `json:"containerID"`
Name string `json:"name"` Name string `json:"name"`
ImageId string `json:"imageID"` ImageId string `json:"imageID"`
-1
View File
@@ -177,7 +177,6 @@ type DiskInfo struct {
} }
type GPUInfo struct { type GPUInfo struct {
SharedMemory bool `json:"sharedMemory"`
Type string `json:"type"` Type string `json:"type"`
Index uint `json:"index"` Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"` NPUIndex uint `json:"npuIndex"`
-1
View File
@@ -56,7 +56,6 @@ type MonitorGPUOptions struct {
Options []string `json:"options"` Options []string `json:"options"`
} }
type GPUChartHide struct { type GPUChartHide struct {
SharedMemory bool `json:"sharedMemory"`
DeviceID string `json:"deviceID"` DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"` Legacy bool `json:"legacy"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
-5
View File
@@ -22,9 +22,6 @@ type AppInstallCreate struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Services map[string]string `json:"services"` Services map[string]string `json:"services"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
GPU *dto.ModelGPUConfig `json:"gpu"`
GPUImageType string `json:"-"`
SyncModelAccount *bool `json:"syncModelAccount"` SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
@@ -131,7 +128,6 @@ type AppInstallDelete struct {
} }
type AppInstalledUpdate struct { type AppInstalledUpdate struct {
GPU *dto.ModelGPUConfig `json:"gpu"`
SyncModelAccount *bool `json:"syncModelAccount"` SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
@@ -160,7 +156,6 @@ type PortUpdate struct {
type AppUpdateVersion struct { type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"` AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"` UpdateVersion string `json:"updateVersion"`
LoadCompose *bool `json:"loadCompose,omitempty"`
} }
type AppInstallSortItem struct { type AppInstallSortItem struct {
-1
View File
@@ -16,7 +16,6 @@ type TensorRTLLMCreate struct {
Command string `json:"command" validate:"required"` Command string `json:"command" validate:"required"`
ModelType string `json:"modelType"` ModelType string `json:"modelType"`
ModelSpeedup bool `json:"modelSpeedup"` ModelSpeedup bool `json:"modelSpeedup"`
GPU *dto.ModelGPUConfig `json:"gpu"`
DockerConfig DockerConfig
} }
-3
View File
@@ -31,7 +31,6 @@ type AppDTO struct {
type AppItem struct { type AppItem struct {
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key"` Key string `json:"key"`
Icon string `json:"icon,omitempty"`
ID uint `json:"id"` ID uint `json:"id"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
@@ -187,7 +186,6 @@ type AppParam struct {
} }
type AppConfig struct { type AppConfig struct {
GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
AppKey string `json:"appKey"` AppKey string `json:"appKey"`
Name string `json:"name"` Name string `json:"name"`
HttpPort int `json:"httpPort"` HttpPort int `json:"httpPort"`
@@ -196,6 +194,5 @@ type AppConfig struct {
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"` Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"` RawCompose string `json:"rawCompose"`
IsEdit bool `json:"isEdit"`
request.AppContainerConfig request.AppContainerConfig
} }
-2
View File
@@ -1,7 +1,6 @@
package response package response
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
) )
@@ -19,7 +18,6 @@ type TensorRTLLMDTO struct {
ModelDir string `json:"modelDir"` ModelDir string `json:"modelDir"`
Image string `json:"image"` Image string `json:"image"`
Command string `json:"command"` Command string `json:"command"`
GPU *dto.ModelGPUConfig `json:"gpu"`
ExposedPorts []request.ExposedPort `json:"exposedPorts"` ExposedPorts []request.ExposedPort `json:"exposedPorts"`
Environments []request.Environment `json:"environments"` Environments []request.Environment `json:"environments"`
@@ -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" }
-6
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -31,7 +30,6 @@ type IAppRepo interface {
BatchCreate(ctx context.Context, apps []model.App) error BatchCreate(ctx context.Context, apps []model.App) error
Create(ctx context.Context, app *model.App) error Create(ctx context.Context, app *model.App) error
Save(ctx context.Context, app *model.App) error Save(ctx context.Context, app *model.App) error
UpdateIcon(id uint, icon string) error
BatchDelete(ctx context.Context, apps []model.App) error BatchDelete(ctx context.Context, apps []model.App) error
DeleteByIDs(ctx context.Context, ids []uint) error DeleteByIDs(ctx context.Context, ids []uint) error
DeleteBy(opts ...DBOption) error DeleteBy(opts ...DBOption) error
@@ -43,10 +41,6 @@ func NewIAppRepo() IAppRepo {
return &AppRepo{} return &AppRepo{}
} }
func (a AppRepo) UpdateIcon(id uint, icon string) error {
return getDb(WithByID(id), a.WithResource(constant.AppResourceRemote)).Model(&model.App{}).UpdateColumn("icon", icon).Error
}
func (a AppRepo) WithByLikeName(name string) DBOption { func (a AppRepo) WithByLikeName(name string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
if len(name) == 0 { if len(name) == 0 {
-16
View File
@@ -17,8 +17,6 @@ type IAppDetailRepo interface {
WithIgnored() DBOption WithIgnored() DBOption
GetFirst(opts ...DBOption) (model.AppDetail, error) GetFirst(opts ...DBOption) (model.AppDetail, error)
Update(ctx context.Context, detail model.AppDetail) error Update(ctx context.Context, detail model.AppDetail) error
CacheCompose(detail model.AppDetail, compose string) (bool, error)
MarkDownloaded(detail model.AppDetail) (bool, error)
BatchCreate(ctx context.Context, details []model.AppDetail) error BatchCreate(ctx context.Context, details []model.AppDetail) error
DeleteByAppIds(ctx context.Context, appIds []uint) error DeleteByAppIds(ctx context.Context, appIds []uint) error
DeleteByIDs(ctx context.Context, appIds []uint) error DeleteByIDs(ctx context.Context, appIds []uint) error
@@ -59,20 +57,6 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) error
return getTx(ctx).Save(&detail).Error return getTx(ctx).Save(&detail).Error
} }
func (a AppDetailRepo) CacheCompose(detail model.AppDetail, compose string) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("docker_compose", compose)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) MarkDownloaded(detail model.AppDetail) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("update", false)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error { func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
} }
+10 -1
View File
@@ -1394,10 +1394,19 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock() agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock() defer agentAccountMutationMu.Unlock()
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID)) account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
return err return err
} }
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64 var agentCount int64
+71 -63
View File
@@ -135,7 +135,6 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
ID: ap.ID, ID: ap.ID,
Name: ap.Name, Name: ap.Name,
Key: ap.Key, Key: ap.Key,
Icon: appicon.RemoteURL(ap.Icon),
Limit: ap.Limit, Limit: ap.Limit,
GpuSupport: ap.GpuSupport, GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend, Recommend: ap.Recommend,
@@ -263,20 +262,10 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
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 return appDetailDTO, err
} }
packageReady := err == nil if err == nil && app.Resource == constant.AppResourceRemote {
if packageReady && app.Resource == constant.AppResourceRemote {
detail.Update = false detail.Update = false
appDetailDTO.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 { switch app.Type {
case constant.RuntimePHP: case constant.RuntimePHP:
paramMap := make(map[string]interface{}) paramMap := make(map[string]interface{})
@@ -307,19 +296,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
if err := validateAppDetailCompose(compose); err != nil { composeMap := make(map[string]interface{})
return appDetailDTO, err
}
var composeMap struct {
Services map[string]interface{} `yaml:"services"`
}
if err := yaml.Unmarshal(compose, &composeMap); err != nil { if err := yaml.Unmarshal(compose, &composeMap); err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
for k := range composeMap.Services { if service, ok := composeMap["services"]; ok {
servicesMap := service.(map[string]interface{})
for k := range servicesMap {
appDetailDTO.Image = k appDetailDTO.Image = k
} }
} }
}
} else { } else {
paramMap := make(map[string]interface{}) paramMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(detail.Params), &paramMap); err != nil { if err := json.Unmarshal([]byte(detail.Params), &paramMap); err != nil {
@@ -328,10 +315,29 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
appDetailDTO.Params = paramMap appDetailDTO.Params = paramMap
} }
if err := ensureAppDetailCompose(app, &detail); err != nil { if appDetailDTO.DockerCompose == "" {
filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
appDetailDTO.DockerCompose = detail.DockerCompose }
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
}
if err != nil {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if statusCode > 200 {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", string(composeRes), err)
}
detail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), detail)
appDetailDTO.DockerCompose = string(composeRes)
}
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose) appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
@@ -349,13 +355,6 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return nil, err
}
if err := ensureAppDetailCompose(app, &appDetail); err != nil {
return nil, err
}
res.AppDetail = appDetail res.AppDetail = appDetail
paramMap := make(map[string]interface{}) paramMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil { if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil {
@@ -443,14 +442,32 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
App: app, App: app,
} }
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
var composeRes []byte
if req.EditCompose { if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return return
} }
} else { } else {
if err = ensureAppDetailCompose(app, &appDetail); err != nil { if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return return
} }
}
var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
err = buserr.New("ErrAppVersionUnavailable")
return
}
if err != nil {
return
}
appDetail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), appDetail)
}
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
return return
} }
@@ -525,26 +542,6 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
composeByte []byte composeByte []byte
paramByte []byte paramByte []byte
) )
if (app.Key == "ollama" || app.Key == "vllm") && req.GPU != nil {
gpuServiceName := appInstall.ServiceName
imageType := "nvidia"
if app.Key == "vllm" {
imageType = req.GPUImageType
if len(servicesMap) > 1 {
gpuServiceName = "vllm"
if _, ok := servicesMap[gpuServiceName]; !ok {
gpuServiceName = "vllm-dspark"
}
}
}
service, ok := servicesMap[gpuServiceName].(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
if err = ApplyModelGPU(service, req.GPU, req.Params, imageType); err != nil {
return
}
}
composeByte, err = yaml.Marshal(composeMap) composeByte, err = yaml.Marshal(composeMap)
if err != nil { if err != nil {
@@ -925,10 +922,22 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res.CanUpdate = true res.CanUpdate = true
return res, err return res, err
} }
list, err := readAppList() apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
if err != nil || list.LastModified != lastModified { for _, app := range apps {
list, err = getAppList() if app.Icon == "" {
res.CanUpdate = true
return res, err
} }
if appicon.IsIconFile(app.Icon) {
fileName, _ := appicon.ParseIconField(app.Icon)
if fileName == "" || !appicon.IconFileExists(fileName) {
res.CanUpdate = true
return res, err
}
}
}
list, err := getAppList()
if err != nil { if err != nil {
return res, err return res, err
} }
@@ -959,26 +968,29 @@ func getAppFromRepo(downloadPath string) error {
} }
func getAppList() (*dto.AppList, error) { func getAppList() (*dto.AppList, error) {
list := &dto.AppList{}
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil { if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
return nil, err return nil, err
} }
return readAppList()
}
func readAppList() (*dto.AppList, error) {
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json") listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
data, err := os.ReadFile(listFile) file, err := os.Open(listFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
list := &dto.AppList{} defer file.Close()
if err = json.Unmarshal(data, list); err != nil { if err = json.NewDecoder(file).Decode(list); err != nil {
return nil, err return nil, err
} }
return list, nil return list, nil
} }
var InitTypes = map[string]struct{}{
"runtime": {},
"php": {},
"node": {},
}
func deleteCustomApp() { func deleteCustomApp() {
installs, err := appInstallRepo.ListBy(context.Background()) installs, err := appInstallRepo.ListBy(context.Background())
if err != nil { if err != nil {
@@ -1076,10 +1088,6 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
return nil, "", "", err return nil, "", "", err
} }
if appicon.IsRemoteURL(app.Icon) {
return nil, app.Icon, "", nil
}
if appicon.IsIconFile(app.Icon) { if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon) fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName) iconBytes, err := appicon.ReadIconFile(fileName)
-341
View File
@@ -1,341 +0,0 @@
package service
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
func appCacheTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cache.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
global.DB = db
global.Dir = global.SystemDir{AppResourceDir: t.TempDir()}
global.Dir.RemoteAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceRemote)
global.Dir.LocalAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceLocal)
global.Dir.CustomAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceCustom)
global.LOG = logrus.New()
global.LOG.SetOutput(io.Discard)
t.Cleanup(func() {
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.Create(&model.Setting{Key: "AppStoreLastModified", Value: "42"}).Error; err != nil {
t.Fatal(err)
}
return db
}
func appCacheTestArchive(t *testing.T, name, content string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(tw, content); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func appCacheTestWrite(t *testing.T, name, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestAppDownloadURLPreservesSourceQuery(t *testing.T) {
for _, rawURL := range []string{
"https://apps.1panel.pro/php/8/php-8.tar.gz",
"https://apps.1panel.pro/php/8/php-8.tar.gz?token=a%2Bb&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
@@ -1,89 +0,0 @@
package service
import (
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"gopkg.in/yaml.v3"
)
func ensureAppDetailCompose(app model.App, detail *model.AppDetail) error {
if strings.TrimSpace(detail.DockerCompose) != "" && validateAppDetailCompose([]byte(detail.DockerCompose)) == nil {
return nil
}
var content []byte
var err error
if app.IsLocalApp() || app.IsCustomApp() {
content, err = os.ReadFile(filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"))
} else {
var composeURL string
if detail.DownloadUrl == "" {
composeURL = fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, detail.Version)
} else {
var source *url.URL
source, err = url.Parse(detail.DownloadUrl)
if err == nil {
source.Path = path.Join(path.Dir(source.Path), "docker-compose.yml")
source.RawPath = ""
composeURL = source.String()
}
}
if err == nil {
composeURL, err = currentAppDownloadURL(composeURL)
}
if err == nil {
var status int
status, content, err = req_helper.HandleRequest(composeURL, http.MethodGet, constant.TimeOut20s)
if status == http.StatusNotFound {
return buserr.New("ErrAppVersionUnavailable")
}
}
}
if err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
return cacheAppDetailCompose(detail, content)
}
func validateAppDetailCompose(content []byte) error {
var compose struct {
Services map[string]map[string]interface{} `yaml:"services"`
}
if err := yaml.Unmarshal(content, &compose); err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if len(compose.Services) == 0 {
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has no services", nil)
}
for name, service := range compose.Services {
if strings.TrimSpace(name) == "" || len(service) == 0 {
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has an empty service", nil)
}
}
return nil
}
func cacheAppDetailCompose(detail *model.AppDetail, content []byte) error {
if err := validateAppDetailCompose(content); err != nil {
return err
}
stored, err := appDetailRepo.CacheCompose(*detail, string(content))
if err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if !stored {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
detail.DockerCompose = string(content)
return nil
}
@@ -1,70 +0,0 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
)
func TestComposeSyncOnlyInvalidatesChangedVersionResources(t *testing.T) {
form := dto.AppForm{FormFields: []dto.AppFormFields{{EnvKey: "PORT", Default: 8080}}}
params, err := json.Marshal(form)
if err != nil {
t.Fatal(err)
}
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, "services:\n demo:\n image: demo:unexpected\n")
}))
t.Cleanup(server.Close)
for _, appType := range []string{"runtime", "php", "node", "java", "go", "python", "dotnet", "website"} {
t.Run(appType, func(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(*model.AppDetail, *dto.AppConfigVersion)
keepCache bool
}{
{"unchanged", func(_ *model.AppDetail, _ *dto.AppConfigVersion) {}, true},
{"package update flag does not expire compose", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Update = true }, true},
{"version revision changed", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified++ }, false},
{"unknown revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = 0 }, false},
{"negative revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = -1 }, false},
{"form changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Params = `{"formFields":[]}` }, false},
{"source changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.DownloadUrl += ".old" }, false},
{"new version", func(d *model.AppDetail, _ *dto.AppConfigVersion) { *d = model.AppDetail{Version: "1.0"} }, false},
} {
t.Run(tc.name, func(t *testing.T) {
const cached = "services:\n demo:\n image: demo:cached\n"
old := model.AppDetail{Version: "1.0", LastModified: 42, Params: string(params), DownloadUrl: server.URL + "/demo/1.0/demo-1.0.tar.gz", DockerCompose: cached}
version := dto.AppConfigVersion{Name: "1.0", LastModified: 42, AppForm: form}
tc.mutate(&old, &version)
ctx := appSyncContext{baseRemoteUrl: server.URL, systemVersion: "2.4.0"}
app := model.App{Key: "demo", Type: appType, Resource: constant.AppResourceRemote, Details: []model.AppDetail{old}}
got := ctx.processOneApp(appWorkItem{app: app, appDef: dto.AppDefine{AppProperty: dto.AppProperty{Key: "demo"}, Versions: []dto.AppConfigVersion{version}}})
if len(got.Details) != 1 || calls.Load() != 0 {
t.Fatalf("sync fetched compose or lost version: %+v, calls=%d", got, calls.Load())
}
detail := got.Details[0]
want := ""
if tc.keepCache {
want = cached
}
if detail.DockerCompose != want || detail.LastModified != version.LastModified || detail.Params != string(params) || !detail.Update {
t.Fatalf("cache decision or version metadata changed: %+v", detail)
}
if app.Details[0].DockerCompose != old.DockerCompose {
t.Fatal("sync mutated its input template")
}
})
}
})
}
}
-253
View File
@@ -1,253 +0,0 @@
package service
import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
const composeTestTemplate = "services:\n demo:\n image: demo:latest\n network_mode: host\n"
func composeTestDetail(t *testing.T, resource, source string) (*gorm.DB, model.App, model.AppDetail) {
t.Helper()
db := appCacheTestDB(t)
app := model.App{Key: "demo", Name: "Demo", Resource: resource, Type: "website"}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
detail := model.AppDetail{AppId: app.ID, Version: "1.0", LastModified: 42, Params: `{"formFields":[]}`, DownloadUrl: source}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
return db, app, detail
}
func TestComposeOnDemandLoadsOnceAndPreservesSource(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/demo/1.0/docker-compose.yml" || r.URL.Query().Get("signature") != "a+b" || r.URL.Query().Get("v") != "42" {
t.Errorf("unexpected Compose source: %s", r.URL)
}
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo/1.0/demo-1.0.tar.gz?signature=a%2Bb")
for i := 0; i < 2; i++ {
if err := ensureAppDetailCompose(app, &detail); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || detail.DockerCompose != composeTestTemplate {
t.Fatalf("cache miss after loading: calls=%d, detail=%+v", calls.Load(), detail)
}
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.DockerCompose != composeTestTemplate || stored.LastModified != 42 || stored.DownloadUrl != detail.DownloadUrl || stored.Params != detail.Params || !stored.UpdatedAt.Equal(detail.UpdatedAt) {
t.Fatalf("cache write altered version metadata: %+v", stored)
}
res, err := (AppService{}).GetAppDetailByID(detail.ID)
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
t.Fatalf("ID details did not reuse template/HostMode: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestComposeOnDemandIDDetailsFillMissingTemplate(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
_, _, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
res, err := (AppService{}).GetAppDetailByID(detail.ID)
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
t.Fatalf("missing template gave wrong ID details: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestComposeOnDemandLocalAndCustomUseTheirDirectories(t *testing.T) {
for _, resource := range []string{constant.AppResourceLocal, constant.AppResourceCustom} {
t.Run(resource, func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
t.Cleanup(server.Close)
_, app, detail := composeTestDetail(t, resource, server.URL+"/must-not-fetch.tar.gz")
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), composeTestTemplate)
if err := ensureAppDetailCompose(app, &detail); err != nil {
t.Fatal(err)
}
if detail.DockerCompose != composeTestTemplate || calls.Load() != 0 {
t.Fatalf("local resource used remote template: %+v, calls=%d", detail, calls.Load())
}
})
}
}
func TestComposeOnDemandRejectsDownloadAndTemplateFailures(t *testing.T) {
for _, tc := range []struct {
name, body string
status int
}{
{"not found", "missing", http.StatusNotFound},
{"server error", "failure", http.StatusServiceUnavailable},
{"empty", "", http.StatusOK},
{"HTML", "<html>blocked</html>", http.StatusOK},
{"malformed YAML", "services: [", http.StatusOK},
{"missing services", "version: '3'", http.StatusOK},
{"empty services", "services: {}", http.StatusOK},
{"empty service", "services:\n demo:", http.StatusOK},
{"string service", "services:\n demo: blocked", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
if err := ensureAppDetailCompose(app, &detail); err == nil {
t.Fatal("invalid template was accepted")
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if detail.DockerCompose != "" || stored.DockerCompose != "" {
t.Fatal("failed response was cached")
}
})
}
}
func TestComposeCacheRejectsConcurrentMetadataUpdates(t *testing.T) {
for _, field := range []string{"last_modified", "params", "download_url"} {
t.Run(field, func(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
value := interface{}("new value")
if field == "last_modified" {
value = 43
}
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "docker_compose": "new template"}).Error; err != nil {
t.Fatal(err)
}
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil {
t.Fatal("stale template overwrote newer metadata")
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.DockerCompose != "new template" || detail.DockerCompose != "" {
t.Fatalf("stale cache result was retained: %+v", stored)
}
})
}
}
func TestComposeCachePropagatesWriteFailure(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
if err := db.Exec("CREATE TRIGGER reject_compose BEFORE UPDATE OF docker_compose ON app_details BEGIN SELECT RAISE(FAIL, 'test write failure'); END").Error; err != nil {
t.Fatal(err)
}
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil || detail.DockerCompose != "" {
t.Fatal("failed cache write was ignored")
}
}
func TestMarkDownloadedPreservesComposeAndRejectsChangedMetadata(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{"update": true, "docker_compose": composeTestTemplate}).Error; err != nil {
t.Fatal(err)
}
stored, err := appDetailRepo.MarkDownloaded(detail)
if err != nil || !stored {
t.Fatalf("matching download marker failed: stored=%v err=%v", stored, err)
}
var fresh model.AppDetail
if err := db.First(&fresh, detail.ID).Error; err != nil || fresh.Update || fresh.DockerCompose != composeTestTemplate {
t.Fatalf("download marker overwrote Compose: %+v, %v", fresh, err)
}
for _, field := range []string{"last_modified", "params", "download_url"} {
t.Run(field, func(t *testing.T) {
var snapshot model.AppDetail
if err := db.First(&snapshot, detail.ID).Error; err != nil {
t.Fatal(err)
}
value := interface{}("new metadata")
if field == "last_modified" {
value = snapshot.LastModified + 1
}
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "update": true}).Error; err != nil {
t.Fatal(err)
}
stored, err := appDetailRepo.MarkDownloaded(snapshot)
if err != nil || stored {
t.Fatalf("stale download marked new resources ready: stored=%v err=%v", stored, err)
}
if err := db.First(&fresh, detail.ID).Error; err != nil || !fresh.Update || fresh.DockerCompose != composeTestTemplate {
t.Fatalf("stale marker overwrote template or update flag: %+v, %v", fresh, err)
}
})
}
}
func TestRuntimeDetailUsesValidPackageComposeWithoutAnotherGET(t *testing.T) {
var calls atomic.Int32
archive := appCacheTestArchive(t, "demo/1.0/docker-compose.yml", composeTestTemplate)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if !strings.HasSuffix(r.URL.Path, ".tar.gz") {
t.Errorf("runtime requested Compose after obtaining a valid package: %s", r.URL)
}
_, _ = w.Write(archive)
}))
t.Cleanup(server.Close)
_, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
app.Type = constant.RuntimeNode
if err := global.DB.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
t.Fatal(err)
}
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
if err != nil || res.DockerCompose != composeTestTemplate || !res.HostMode || calls.Load() != 1 {
t.Fatalf("package Compose was not reused: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestRuntimeDetailDoesNotCacheStaleTemplateAfterPackageFailure(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if strings.HasSuffix(r.URL.Path, ".tar.gz") {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
app.Type = constant.RuntimeNode
if err := db.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
t.Fatal(err)
}
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), "services:\n stale:\n image: stale:old\n")
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
if err != nil || res.DockerCompose != composeTestTemplate || calls.Load() != 2 {
t.Fatalf("failed package download reused a stale local template: %+v, %v, calls=%d", res, err, calls.Load())
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil || stored.DockerCompose != composeTestTemplate {
t.Fatalf("stale package contents were cached: %+v, %v", stored, err)
}
}
@@ -1,253 +0,0 @@
package service
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const composeUpgradeCurrent = `services:
demo:
image: demo:1.0
deploy:
resources:
limits:
cpus: ${CPUS}
memory: ${MEMORY_LIMIT}
`
func composeUpgradeDB(t *testing.T, resourceURL string) (*gorm.DB, model.App, model.AppInstall, []model.AppDetail) {
t.Helper()
db := iconSyncDB(t)
if err := db.AutoMigrate(&model.AppIgnoreUpgrade{}); err != nil {
t.Fatal(err)
}
app := model.App{Key: "demo", Name: "Demo", Type: "runtime", Resource: constant.AppResourceRemote, CrossVersionUpdate: true}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
var details []model.AppDetail
for _, version := range []string{"1.1", "1.0", "1.3", "1.2"} {
detail := model.AppDetail{
AppId: app.ID, Version: version, LastModified: 42, Params: `{"formFields":[]}`, Status: constant.AppNormal,
DownloadUrl: resourceURL + "/demo/" + version + "/demo-" + version + ".tar.gz",
}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
details = append(details, detail)
}
if err := db.Create(&model.AppIgnoreUpgrade{AppID: app.ID, AppDetailID: details[2].ID, Scope: "version"}).Error; err != nil {
t.Fatal(err)
}
install := model.AppInstall{
AppId: app.ID, AppDetailId: details[1].ID, Name: "demo-install", Version: "1.0", App: app,
ServiceName: "demo", ContainerName: "demo-container", Status: constant.StatusRunning,
Env: `{"CONTAINER_NAME":"demo-container"}`, DockerCompose: composeUpgradeCurrent,
}
if err := db.Omit(clause.Associations).Create(&install).Error; err != nil {
t.Fatal(err)
}
return db, app, install, details
}
func TestUpgradeComposeLoadsOnlyEffectiveTarget(t *testing.T) {
var mu sync.Mutex
requests := make(map[string]int)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requests[r.URL.Path]++
mu.Unlock()
if r.URL.Query().Get("v") != "42" {
t.Errorf("missing synchronized version: %s", r.URL)
}
version := strings.Split(r.URL.Path, "/")[2]
_, _ = fmt.Fprintf(w, "services:\n source:\n image: demo:%s\n", version)
}))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
// An unselected bad cached template must not prevent listing or default preview.
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "invalid: [").Error; err != nil {
t.Fatal(err)
}
service := NewIAppInstalledService()
noCompose := false
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, LoadCompose: &noCompose})
if err != nil || len(versions) != 2 || versions[0].Version != "1.2" || versions[1].Version != "1.1" {
t.Fatalf("pure versions did not preserve filtering/sorting: %+v, %v", versions, err)
}
for _, version := range versions {
if version.DockerCompose != "" {
t.Fatal("pure version request returned a rendered template")
}
}
if len(requests) != 0 {
t.Fatalf("pure version request fetched templates: %v", requests)
}
for i := 0; i < 2; i++ {
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
if err != nil || len(versions) != 2 || !strings.Contains(versions[0].DockerCompose, "demo:1.2") || versions[1].DockerCompose != "" {
t.Fatalf("default target preview = %+v, %v", versions, err)
}
}
if requests["/demo/1.2/docker-compose.yml"] != 1 || len(requests) != 1 {
t.Fatalf("default preview did not reuse only its own cache: %v", requests)
}
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "").Error; err != nil {
t.Fatal(err)
}
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.1"})
if err != nil || !strings.Contains(versions[1].DockerCompose, "demo:1.1") || versions[0].DockerCompose != "" {
t.Fatalf("selected target preview = %+v, %v", versions, err)
}
if requests["/demo/1.1/docker-compose.yml"] != 1 || len(requests) != 2 {
t.Fatalf("selection prefetched other candidates: %v", requests)
}
if _, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.3"}); err == nil {
t.Fatal("ignored target was accepted")
}
if len(requests) != 2 {
t.Fatal("invalid target triggered a template request")
}
}
func TestUpgradeComposeFetchFailurePreservesInstanceAndCache(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
if _, err := NewIAppInstalledService().GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID}); err == nil {
t.Fatal("failed target template load was accepted")
}
var stored model.AppInstall
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Status != install.Status || stored.Version != install.Version {
t.Fatalf("preview modified installed instance: %+v, %v", stored, err)
}
var detail model.AppDetail
if err := db.First(&detail, details[3].ID).Error; err != nil || detail.DockerCompose != "" {
t.Fatalf("failed response cached a template: %+v, %v", detail, err)
}
}
func TestUpgradePrepareComposeFailureDoesNotCutover(t *testing.T) {
for _, failure := range []string{"package download", "invalid package template"} {
t.Run(failure, func(t *testing.T) {
archive := appCacheTestArchive(t, "demo/1.2/docker-compose.yml", "services: []")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failure == "package download" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(archive)
}))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
upgrade := &appUpgradeContext{original: install, detail: details[3]}
if err := upgrade.prepare(&task.Task{Logger: global.LOG}); err == nil {
t.Fatal("invalid upgrade resources passed prepare")
}
if upgrade.stopAttempted || upgrade.downAttempted || upgrade.stageDir != "" {
t.Fatal("failed template preparation reached live cutover")
}
var stored model.AppInstall
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Env != install.Env || stored.Version != install.Version || stored.Status != install.Status {
t.Fatalf("failed prepare modified the instance: %+v, %v", stored, err)
}
})
}
}
func TestExplicitConfigComposeDetectsCustomAndReusesCurrentCache(t *testing.T) {
for _, tc := range []struct {
name string
custom bool
failure bool
}{
{name: "original"},
{name: "custom", custom: true},
{name: "fetch failure", failure: true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/demo/1.0/docker-compose.yml" {
t.Errorf("configuration loaded a different version: %s", r.URL)
}
if tc.failure {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, "services:\n source:\n image: demo:1.0\n")
}))
t.Cleanup(server.Close)
db, _, install, _ := composeUpgradeDB(t, server.URL)
if tc.custom {
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n environment:\n USER_SETTING: preserve", 1)
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
}
if isEditCompose(install) || calls != 0 {
t.Fatal("list-time custom check fetched a missing template")
}
result, err := NewIAppInstalledService().GetParams(install.ID)
if tc.failure {
if err == nil || result != nil {
t.Fatal("failed current-template load was treated as an unedited configuration")
}
return
}
if err != nil || result.RawCompose == "" || result.IsEdit != tc.custom || result.DockerCompose != install.DockerCompose {
t.Fatalf("explicit custom state = %+v, %v; want custom=%v", result, err, tc.custom)
}
if _, err := NewIAppInstalledService().GetParams(install.ID); err != nil || calls != 1 {
t.Fatalf("configuration cache was not reused: calls=%d, %v", calls, err)
}
if isEditCompose(install) != tc.custom || calls != 1 {
t.Fatal("cached list-time comparison changed custom state or made network requests")
}
})
}
}
func TestVllmManagedComposeDoesNotRequireOfficialTemplate(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(server.Close)
db, app, install, _ := composeUpgradeDB(t, server.URL)
if err := db.Model(&app).UpdateColumn("key", vllmAppKeyForUpgrade).Error; err != nil {
t.Fatal(err)
}
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n command: [--managed-user-option]", 1)
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
service := NewIAppInstalledService()
config, err := service.GetParams(install.ID)
if err != nil || config.RawCompose != install.DockerCompose || config.DockerCompose != install.DockerCompose || config.IsEdit {
t.Fatalf("managed configuration lost the instance template: %+v, %v", config, err)
}
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
if err != nil || len(versions) != 2 || versions[0].DockerCompose != install.DockerCompose {
t.Fatalf("managed upgrade preview lost the instance template: %+v, %v", versions, err)
}
if calls.Load() != 0 {
t.Fatalf("managed template attempted %d official resource requests", calls.Load())
}
}
-265
View File
@@ -1,265 +0,0 @@
package service
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/gin-gonic/gin"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"gorm.io/gorm"
)
type iconSyncTransport struct {
providers.MultiNodeProvider
transport *http.Transport
}
func (p iconSyncTransport) LoadRequestTransport() *http.Transport { return p.transport }
func iconSyncDB(t *testing.T) *gorm.DB {
t.Helper()
db := appCacheTestDB(t)
if err := db.AutoMigrate(&model.Tag{}, &model.AppInstall{}, &model.AppInstallResource{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.Setting{{Key: "SystemVersion", Value: "2.4.0"}, {Key: "AppStoreSyncStatus", Value: constant.StatusSyncSuccess}}).Error; err != nil {
t.Fatal(err)
}
oldI18n := global.I18n
global.I18n = goi18n.NewLocalizer(goi18n.NewBundle(language.English), "en")
t.Cleanup(func() { global.I18n = oldI18n })
return db
}
func TestOfficialIconSyncNeverDownloadsImages(t *testing.T) {
iconSyncDB(t)
var calls atomic.Int32
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(cdn.Close)
taskLog := &task.Task{Logger: global.LOG}
for _, oldIcon := range []string{"", "app_demo.png?etag=old", "b2xk", cdn.URL + "/old.png"} {
t.Run(oldIcon, func(t *testing.T) {
icon := cdn.URL + "/demo.png?signature=a%2Bb&v=one"
def := dto.AppDefine{Icon: icon, AppProperty: dto.AppProperty{Key: "demo", Type: "website"}, Versions: []dto.AppConfigVersion{{Name: "1.0", AppForm: dto.AppForm{}}}}
apps := getApps([]model.App{{Key: "demo", Icon: oldIcon}}, []dto.AppDefine{def}, "2.4.0", taskLog)
ctx := appSyncContext{baseRemoteUrl: cdn.URL, list: &dto.AppList{LastModified: 42}}
result := ctx.processOneApp(appWorkItem{app: apps["demo"], appDef: def})
if result.Icon != icon || len(result.Details) != 1 || result.Details[0].Version != "1.0" || calls.Load() != 0 {
t.Fatalf("sync changed metadata or fetched a CDN image: %+v, calls=%d", result, calls.Load())
}
})
}
for _, invalid := range []string{"", "javascript:alert(1)", "https://user:secret@example.com/a.png", "//example.com/a.png", "https://"} {
def := dto.AppDefine{Icon: invalid, AppProperty: dto.AppProperty{Key: "demo"}}
apps := getApps([]model.App{{Key: "demo", Icon: "b2xk"}}, []dto.AppDefine{def}, "2.4.0", taskLog)
if apps["demo"].Icon != "b2xk" {
t.Fatalf("invalid icon %q erased history", invalid)
}
}
}
func TestOfficialIconSameRevisionRefreshIsReadOnlyUntilSync(t *testing.T) {
db := iconSyncDB(t)
apps := []model.App{
{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true},
{Key: "demo", Name: "original", Type: "website", Status: constant.AppNormal, Icon: "b2xk", LastModified: 7},
{Key: "future", Type: "website", Status: constant.AppNormal, Icon: "b2xk"},
{Key: "local-demo", Type: "website", Resource: constant.AppResourceLocal, Status: constant.AppNormal, Icon: "bG9jYWw="},
{Key: "custom-demo", Type: "website", Resource: constant.AppResourceCustom, Status: constant.AppNormal, Icon: "Y3VzdG9t"},
{Key: "down", Type: "website", Status: constant.AppTakeDown, Icon: "b2xk"},
}
if err := db.Create(&apps).Error; err != nil {
t.Fatal(err)
}
detail := model.AppDetail{AppId: apps[1].ID, Version: "1.0", Params: "keep", DockerCompose: "keep", Update: false}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&detail, detail.ID).Error; err != nil {
t.Fatal(err)
}
var imageCalls, indexCalls atomic.Int32
var failIndex atomic.Bool
list := dto.AppList{LastModified: 42}
for _, app := range apps {
version := 0.0
if app.Key == "future" {
version = 9.9
}
list.Apps = append(list.Apps, dto.AppDefine{Icon: "https://cdn.example/" + app.Key + ".png?signature=a%2Bb", AppProperty: dto.AppProperty{Key: app.Key, Version: version}})
}
var zipped bytes.Buffer
zw := zip.NewWriter(&zipped)
w, err := zw.Create("1panel.json")
if err != nil {
t.Fatal(err)
}
if err := json.NewEncoder(w).Encode(list); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
_, _ = io.WriteString(w, "42")
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
indexCalls.Add(1)
if failIndex.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(zipped.Bytes())
default:
imageCalls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
t.Cleanup(server.Close)
transport := server.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig.InsecureSkipVerify = true
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
}
oldProvider := xpack.MultiNodeProvider
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
global.Dir.ResourceDir = t.TempDir()
global.Dir.IconCacheDir = t.TempDir()
appCacheTestWrite(t, filepath.Join(global.Dir.IconCacheDir, "app_mysql.png"), "keep old image")
encodedList, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), string(encodedList))
res, err := (AppService{}).GetAppUpdate()
if err != nil || res.CanUpdate || res.AppList == nil || indexCalls.Load() != 0 {
t.Fatalf("same revision/missing files forced a full sync: %+v, %v", res, err)
}
var original model.App
if err := db.First(&original, apps[1].ID).Error; err != nil || original.Icon != apps[1].Icon {
t.Fatalf("update check modified icon: %+v, %v", original, err)
}
var writes atomic.Int32
if err := db.Callback().Update().After("gorm:update").Register("test:icon-writes", func(tx *gorm.DB) {
if values, ok := tx.Statement.Dest.(map[string]interface{}); ok && tx.Statement.Table == "apps" && values["icon"] != nil {
writes.Add(1)
}
}); err != nil {
t.Fatal(err)
}
var shared *appSyncContext
for i := 0; i < 2; i++ {
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err != nil {
t.Fatal(err)
}
if shared == nil || !shared.skipMetaSync || writes.Load() != 2 || imageCalls.Load() != 0 || indexCalls.Load() != 0 {
t.Fatalf("unexpected same revision sync: writes=%d, image calls=%d, index calls=%d, context=%+v", writes.Load(), imageCalls.Load(), indexCalls.Load(), shared)
}
}
var stored model.App
if err := db.First(&stored, apps[1].ID).Error; err != nil {
t.Fatal(err)
}
want := original
want.Icon = list.Apps[1].Icon
if !reflect.DeepEqual(stored, want) {
t.Fatalf("light refresh changed non-icon fields: got %+v, want %+v", stored, want)
}
for _, original := range apps[2:] {
stored = model.App{}
if err := db.First(&stored, original.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Icon != original.Icon || stored.Status != original.Status {
t.Fatalf("filtered app was changed: %+v", stored)
}
}
var storedDetail model.AppDetail
if err := db.First(&storedDetail, detail.ID).Error; err != nil || !reflect.DeepEqual(storedDetail, detail) {
t.Fatalf("light refresh touched app details: %+v, %v", storedDetail, err)
}
failIndex.Store(true)
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), "{invalid")
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err == nil || writes.Load() != 2 || indexCalls.Load() != 1 {
t.Fatal("failed index read was accepted or modified icons")
}
}
func TestAppIconURLsReachListDetailAndInstalled(t *testing.T) {
db := iconSyncDB(t)
for _, icon := range []string{"https://cdn.example/icon.png?signature=a%2Bb", "app_legacy.png?etag=old", "bG9jYWw="} {
app := model.App{Key: "demo-" + icon[:4], Name: "demo", Type: "website", Status: constant.AppNormal, Icon: icon}
app.Name = app.Key
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
got, err := (AppService{}).PageApp(ctx, request.AppSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Name: app.Key})
if err != nil || len(got.Items) != 1 {
t.Fatalf("list failed: %+v, %v", got, err)
}
want := ""
if strings.HasPrefix(icon, "https:") {
want = icon
}
if got.Items[0].Icon != want {
t.Fatalf("list icon = %q, want %q", got.Items[0].Icon, want)
}
detail, err := (AppService{}).GetApp(ctx, app.Key)
if err != nil || detail.Icon != icon {
t.Fatalf("detail icon = %+v, %v", detail, err)
}
installed, err := handleInstalled([]model.AppInstall{{App: app, Status: constant.StatusRunning}}, false, false, false)
if err != nil || len(installed) != 1 || installed[0].Icon != want {
t.Fatalf("installed icon = %+v, %v", installed, err)
}
}
}
func TestLocalAppLogoKeepsBase64Delivery(t *testing.T) {
db := iconSyncDB(t)
dir := t.TempDir()
const image = "\x89PNG\r\n\x1a\nlocal-icon"
appCacheTestWrite(t, filepath.Join(dir, "data.yml"), "additionalProperties:\n key: demo\n name: Local Demo\n type: website\n")
appCacheTestWrite(t, filepath.Join(dir, "README.md"), "local application")
appCacheTestWrite(t, filepath.Join(dir, "logo.png"), image)
app, err := handleLocalApp(dir)
if err != nil || app.Resource != constant.AppResourceLocal || app.Icon != base64.StdEncoding.EncodeToString([]byte(image)) {
t.Fatalf("local logo parsing changed: %+v, %v", app, err)
}
if err := db.Create(app).Error; err != nil {
t.Fatal(err)
}
data, source, etag, err := (AppService{}).GetAppIcon(app.Key)
if err != nil || string(data) != image || source != "" || etag != "" {
t.Fatalf("local logo no longer uses historical delivery: %q, %q, %q, %v", data, source, etag, err)
}
}
+42 -115
View File
@@ -6,8 +6,10 @@ import (
"fmt" "fmt"
"maps" "maps"
"math" "math"
"net/http"
"os" "os"
"path" "path"
"path/filepath"
"reflect" "reflect"
"sort" "sort"
"strconv" "strconv"
@@ -29,6 +31,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/env" "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx" "github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -256,15 +259,6 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil { if err != nil {
return err return err
} }
if install.Status == constant.StatusRestarting && (req.Operate == constant.Stop || req.Operate == constant.Delete) && !req.ForceDelete {
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(install.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return buserr.New("TaskIsExecuting")
}
}
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) { if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
return buserr.New("ErrInstallDirNotFound") return buserr.New("ErrInstallDirNotFound")
} }
@@ -348,6 +342,9 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
return errors.New("lifecycle script operation not supported") return errors.New("lifecycle script operation not supported")
} }
install.Message = "" install.Message = ""
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID) operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil { if err != nil {
@@ -358,17 +355,10 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
} }
return err return err
} }
ready := make(chan error, 1)
started := make(chan struct{})
operation := string(req.Operate) operation := string(req.Operate)
operationTask.AddSubTaskWithOps( operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp), task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
func(t *task.Task) error { func(t *task.Task) error {
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
close(started)
ready <- nil
if err := runScript(t, &install, operation); err != nil { if err := runScript(t, &install, operation); err != nil {
return err return err
} }
@@ -395,12 +385,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
) )
go func() { go func() {
if taskErr := operationTask.Execute(); taskErr != nil { if taskErr := operationTask.Execute(); taskErr != nil {
select {
case <-started:
default:
ready <- taskErr
return
}
if onFailure != nil { if onFailure != nil {
onFailure(taskErr) onFailure(taskErr)
return return
@@ -410,7 +394,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
_ = appInstallRepo.Save(context.Background(), &install) _ = appInstallRepo.Save(context.Background(), &install)
} }
}() }()
return <-ready return nil
} }
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error { func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
@@ -490,10 +474,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
} }
backupDockerCompose := installed.DockerCompose backupDockerCompose := installed.DockerCompose
updateGPU := installed.App.Key == "ollama" && req.GPU != nil if req.Advanced {
if req.Advanced || updateGPU {
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
if req.Advanced && req.EditCompose { if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return err return err
} }
@@ -502,35 +485,16 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err return err
} }
} }
if req.Advanced && !req.SkipComposeCommonConfig { if !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil { if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err return err
} }
} }
if updateGPU {
services, _ := composeMap["services"].(map[string]interface{})
service, ok := services[installed.ServiceName].(map[string]interface{})
if !ok {
return buserr.New("ErrFileParse")
}
gpuParams := map[string]interface{}{}
if err := json.Unmarshal([]byte(installed.Env), &gpuParams); err != nil {
return err
}
for key, value := range req.Params {
gpuParams[key] = value
}
if err := ApplyModelGPU(service, req.GPU, gpuParams, "nvidia"); err != nil {
return err
}
}
composeByte, err := yaml.Marshal(composeMap) composeByte, err := yaml.Marshal(composeMap)
if err != nil { if err != nil {
return err return err
} }
installed.DockerCompose = string(composeByte) installed.DockerCompose = string(composeByte)
}
if req.Advanced {
if req.ContainerName == "" { if req.ContainerName == "" {
req.Params[constant.ContainerName] = installed.ContainerName req.Params[constant.ContainerName] = installed.ContainerName
} else { } else {
@@ -779,7 +743,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
if canUpgrade { if canUpgrade {
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version) feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked && (req.LoadCompose == nil || *req.LoadCompose) { if feishuTarget && !feishuChecked {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json")) conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil { if readErr != nil {
return versions, readErr return versions, readErr
@@ -790,6 +754,33 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
feishuChecked = true feishuChecked = true
} }
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")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return versions, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable")
}
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
}
if app.Key == constant.AppMysql { if app.Key == constant.AppMysql {
majorVersion := getMajorVersion(install.Version) majorVersion := getMajorVersion(install.Version)
if !strings.HasPrefix(detail.Version, majorVersion) { if !strings.HasPrefix(detail.Version, majorVersion) {
@@ -801,46 +792,13 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
FeishuConfigured: feishuTarget && feishuConfigured, FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version, Version: detail.Version,
DetailId: detail.ID, DetailId: detail.ID,
DockerCompose: newCompose,
}) })
} }
} }
sort.Slice(versions, func(i, j int) bool { sort.Slice(versions, func(i, j int) bool {
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version) return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
}) })
if len(versions) == 0 || (req.LoadCompose != nil && !*req.LoadCompose) {
return versions, nil
}
target := 0
if req.UpdateVersion != "" {
target = -1
for i := range versions {
if versions[i].Version == req.UpdateVersion {
target = i
break
}
}
if target == -1 {
return versions, buserr.New("ErrAppVersionUnavailable")
}
}
if app.Key == vllmAppKeyForUpgrade {
versions[target].DockerCompose = install.DockerCompose
return versions, nil
}
for _, detail := range details {
if detail.ID != versions[target].DetailId {
continue
}
if err := ensureAppDetailCompose(app, &detail); err != nil {
return versions, err
}
content, err := getUpgradeCompose(install, detail)
if err != nil {
return versions, err
}
versions[target].DockerCompose = content
break
}
return versions, nil return versions, nil
} }
@@ -1036,16 +994,6 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" { 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) res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1059,22 +1007,9 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.RestartPolicy = getRestartPolicy(install.DockerCompose) res.RestartPolicy = getRestartPolicy(install.DockerCompose)
res.WebUI = install.WebUI res.WebUI = install.WebUI
res.Type = install.App.Type res.Type = install.App.Type
rawCompose := install.DockerCompose if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
if install.App.Key != vllmAppKeyForUpgrade {
if err := ensureAppDetailCompose(install.App, &detail); err != nil {
return nil, err
}
rawCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return nil, err
}
}
equal, err := composeEqualExceptImage(rawCompose, install.DockerCompose)
if err != nil {
return nil, err
}
res.RawCompose = rawCompose res.RawCompose = rawCompose
res.IsEdit = !equal }
return &res, nil return &res, nil
} }
@@ -1101,16 +1036,8 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
} }
switch appInstall.Status { switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling, case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusWaiting: constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil return nil
case constant.StatusRestarting:
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return nil
}
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr: case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force { if !force {
return nil return nil
-128
View File
@@ -1,128 +0,0 @@
package service
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
func TestAppStoreIndexUsesCacheUntilRevisionChanges(t *testing.T) {
for _, tc := range []struct {
name string
cache string
version int
failZIP bool
wantZIP int32
wantUpdate bool
wantErr bool
}{
{name: "matching cache", cache: `{"lastModified":42}`, version: 42},
{name: "missing cache", version: 42, wantZIP: 1},
{name: "malformed cache", cache: `{invalid`, version: 42, wantZIP: 1},
{name: "null cache", cache: `null`, version: 42, wantZIP: 1},
{name: "trailing corrupt data", cache: `{"lastModified":42} garbage`, version: 42, wantZIP: 1},
{name: "outdated cache", cache: `{"lastModified":41}`, version: 42, wantZIP: 1},
{name: "remote revision changed", cache: `{"lastModified":42}`, version: 43, wantUpdate: true},
{name: "download failure", cache: `{invalid`, version: 42, failZIP: true, wantZIP: 1, wantErr: true},
{name: "cached minimum panel version", cache: `{"lastModified":42,"additionalProperties":{"version":"9.9.0"}}`, version: 42, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
db := iconSyncDB(t)
if err := db.Create(&model.App{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true}).Error; err != nil {
t.Fatal(err)
}
global.Dir.ResourceDir = t.TempDir()
cachePath := filepath.Join(global.Dir.ResourceDir, "1panel.json")
if tc.cache != "" {
appCacheTestWrite(t, cachePath, tc.cache)
}
list := dto.AppList{LastModified: tc.version, Apps: []dto.AppDefine{{Icon: "https://cdn.example/mysql.png", AppProperty: dto.AppProperty{Key: "mysql"}}}}
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
entry, err := zw.Create("1panel.json")
if err != nil {
t.Fatal(err)
}
if err := json.NewEncoder(entry).Encode(list); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var versionCalls, zipCalls atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
versionCalls.Add(1)
_, _ = io.WriteString(w, strconv.Itoa(tc.version))
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
zipCalls.Add(1)
if tc.failZIP {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(archive.Bytes())
default:
t.Errorf("unexpected remote resource request: %s", r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
transport := server.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig.InsecureSkipVerify = true
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
}
oldProvider := xpack.MultiNodeProvider
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
res, err := (AppService{}).GetAppUpdate()
if (err != nil) != tc.wantErr || res == nil && !tc.wantErr {
t.Fatalf("update check = %+v, %v; want error %v", res, err, tc.wantErr)
}
if zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 1 {
t.Fatalf("first check requests: version=%d, ZIP=%d; want 1/%d", versionCalls.Load(), zipCalls.Load(), tc.wantZIP)
}
if tc.wantErr {
if tc.wantZIP == 0 && !strings.Contains(err.Error(), "ErrVersionTooLow") {
t.Fatalf("cached panel-version check was lost: %v", err)
}
return
}
if res.CanUpdate != tc.wantUpdate {
t.Fatalf("CanUpdate=%v, want %v", res.CanUpdate, tc.wantUpdate)
}
if tc.wantUpdate {
// Full synchronization still fetches the new index after detecting a revision change.
fresh, err := getAppList()
if err != nil || fresh.LastModified != tc.version || zipCalls.Load() != 1 {
t.Fatalf("changed revision did not fetch the new index: %+v, %v, ZIP=%d", fresh, err, zipCalls.Load())
}
return
}
if res.AppList == nil || res.AppList.LastModified != 42 {
t.Fatalf("same-revision icon refresh lost its index: %+v", res)
}
if _, err := (AppService{}).GetAppUpdate(); err != nil || zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 2 {
t.Fatalf("second check redownloaded cached index: %v, version=%d, ZIP=%d", err, versionCalls.Load(), zipCalls.Load())
}
})
}
}
+211 -45
View File
@@ -4,7 +4,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -12,11 +17,15 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
) )
type appSyncContext struct { type appSyncContext struct {
task *task.Task task *task.Task
httpClient http.Client
baseRemoteUrl string baseRemoteUrl string
systemVersion string systemVersion string
appsMap map[string]model.App appsMap map[string]model.App
@@ -25,6 +34,7 @@ type appSyncContext struct {
oldAppIds []uint oldAppIds []uint
appTags []*model.AppTag appTags []*model.AppTag
skipMetaSync bool skipMetaSync bool
pendingIcons map[string]string
} }
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error { func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
@@ -41,10 +51,6 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing")) t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil return nil
} }
if err := refreshOfficialAppIcons(updateRes.AppList, t); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
return err
}
global.LOG.Infof("[AppStore] Appstore is up to date") global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate")) t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true} *sharedCtx = &appSyncContext{skipMetaSync: true}
@@ -76,11 +82,13 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx := &appSyncContext{ ctx := &appSyncContext{
task: t, task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.MultiNodeProvider.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode), baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
systemVersion: setting.SystemVersion, systemVersion: setting.SystemVersion,
settingService: settingService, settingService: settingService,
list: list, list: list,
appTags: make([]*model.AppTag, 0), appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
} }
if err = SyncTags(list.Extra); err != nil { if err = SyncTags(list.Extra); err != nil {
@@ -99,7 +107,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx.oldAppIds = append(ctx.oldAppIds, old.ID) ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
} }
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t) ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
var addCount, updateCount, deleteCount int var addCount, updateCount, deleteCount int
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil { if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
@@ -121,39 +129,40 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
} }
} }
func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
if list == nil {
return nil
}
apps, err := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
if err != nil {
return err
}
systemVersion, err := settingRepo.GetValueByKey("SystemVersion")
if err != nil {
return err
}
updated := getApps(apps, list.Apps, systemVersion, t)
for _, app := range apps {
candidate := updated[app.Key]
if app.Status != constant.AppNormal || candidate.Status != constant.AppNormal || candidate.Icon == app.Icon {
continue
}
if err := appRepo.UpdateIcon(app.ID, candidate.Icon); err != nil {
return err
}
}
return nil
}
type appWorkItem struct { type appWorkItem struct {
appDef dto.AppDefine appDef dto.AppDefine
app model.App app model.App
iconUrl string
hadIcon bool
} }
func (c *appSyncContext) processOneApp(item appWorkItem) model.App { type appWorkResult struct {
appKey string
app model.App
iconStatus int
hadIcon bool
httpFailed bool
}
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
app := item.app app := item.app
l := item.appDef l := item.appDef
result := appWorkResult{
appKey: l.AppProperty.Key,
hadIcon: item.hadIcon,
}
if item.hadIcon {
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon)
result.iconStatus = status
switch status {
case http.StatusOK:
app.Icon = iconField
case http.StatusNotModified:
default:
result.httpFailed = true
}
}
app.TagsKey = l.AppProperty.Tags app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 { if l.AppProperty.Recommend > 0 {
@@ -180,12 +189,24 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
continue continue
} }
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz") if _, ok := InitTypes[app.Type]; ok {
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL { dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
var composeRes []byte
if err == nil {
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
}
if err == nil {
detail.DockerCompose = string(composeRes)
} else {
result.httpFailed = true
}
} else {
detail.DockerCompose = "" detail.DockerCompose = ""
} }
detail.Params = string(paramByte) detail.Params = string(paramByte)
detail.DownloadUrl = downloadURL detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
detail.DownloadCallBackUrl = v.DownloadCallBackUrl detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true detail.Update = true
detail.LastModified = v.LastModified detail.LastModified = v.LastModified
@@ -198,25 +219,170 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
} }
app.Details = newDetails app.Details = newDetails
return app result.app = app
return result
} }
func (c *appSyncContext) syncAppDetails() error { func (c *appSyncContext) syncAppIconsAndDetails() error {
total := len(c.list.Apps) total := len(c.list.Apps)
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total) global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
for i, definition := range c.list.Apps {
app, ok := c.appsMap[definition.AppProperty.Key] workItems := make([]appWorkItem, 0, total)
if ok { for _, l := range c.list.Apps {
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app}) app, ok := c.appsMap[l.AppProperty.Key]
if !ok {
continue
} }
if (i+1)%max(1, total/4) == 0 || i+1 == total { iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total) workItems = append(workItems, appWorkItem{
appDef: l,
app: app,
iconUrl: iconUrl,
hadIcon: hasPending,
})
} }
}
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total) totalWork := len(workItems)
if totalWork == 0 {
return nil 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++
}
}
applyResult := func(result appWorkResult) {
c.appsMap[result.appKey] = result.app
if result.hadIcon {
switch result.iconStatus {
case http.StatusOK:
icon200Count++
case http.StatusNotModified:
icon304Count++
default:
iconFailCount++
}
}
completed++
checkProgress()
}
for result := range resultCh {
applyResult(result)
}
fedCount := int(fed.Load())
if failFlag.Load() && fedCount < len(workItems) {
global.LOG.Warnf("[AppStore] HTTP failure detected, falling back to serial for remaining %d apps", len(workItems)-fedCount)
for i := fedCount; i < len(workItems); i++ {
result := c.processOneApp(workItems[i])
applyResult(result)
}
}
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
total, icon200Count, icon304Count, iconFailCount)
return nil
}
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
iconFileName, existingEtag := appicon.ParseIconField(oldIcon)
reqHeaders := make(map[string]string)
if existingEtag != "" && iconFileName != "" && appicon.IconFileExists(iconFileName) {
reqHeaders["If-None-Match"] = existingEtag
}
resp, err := req_helper.HandleRequestWithHeaders(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s, reqHeaders)
if err != nil {
global.LOG.Warnf("[AppStore] request icon failed url=%s, err=%v", iconUrl, err)
return 0, ""
}
if resp.StatusCode == http.StatusNotModified {
return http.StatusNotModified, ""
}
if resp.StatusCode != http.StatusOK {
global.LOG.Warnf("[AppStore] download icon failed url=%s, status=%d", iconUrl, resp.StatusCode)
return 0, ""
}
if len(resp.Body) == 0 {
global.LOG.Warnf("[AppStore] download icon empty body url=%s", iconUrl)
return 0, ""
}
if resp.Body[0] == '<' {
global.LOG.Warnf("[AppStore] download icon got HTML response url=%s", iconUrl)
return 0, ""
}
contentType := resp.Header.Get("Content-Type")
ct := strings.TrimSpace(strings.Split(contentType, ";")[0])
if strings.ToLower(ct) != "image/png" {
global.LOG.Warnf("[AppStore] unexpected icon content-type: %s, expected image/png, url=%s", ct, iconUrl)
}
fileName, err := appicon.WriteIconFile(appKey, resp.Body)
if err != nil {
global.LOG.Warnf("[AppStore] write icon file failed appKey=%s, err=%v", appKey, err)
return 0, ""
}
newEtag := resp.Header.Get("ETag")
iconField = appicon.BuildIconField(fileName, newEtag)
return http.StatusOK, iconField
}
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error { func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) { return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail")) t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
@@ -245,7 +411,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
ctx.task = t ctx.task = t
ctx.appTags = make([]*model.AppTag, 0) ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppDetails(); err != nil { if err = ctx.syncAppIconsAndDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err) t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err return err
} }
+2 -3
View File
@@ -216,9 +216,8 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err != nil { if err != nil {
return err return err
} }
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil { u.detail.DockerCompose = string(composeContent)
return err _ = appDetailRepo.Update(context.Background(), u.detail)
}
} }
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" { if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml") return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
+7 -15
View File
@@ -28,7 +28,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose" "github.com/1Panel-dev/1Panel/agent/utils/compose"
@@ -994,13 +993,8 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil { if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err return err
} }
marked, err := appDetailRepo.MarkDownloaded(appDetail) appDetail.Update = false
if err != nil { _ = appDetailRepo.Update(context.Background(), appDetail)
return err
}
if !marked {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
return return
} }
@@ -1274,8 +1268,9 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails return appDetails
} }
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App { func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
apps := make(map[string]model.App, len(oldApps)) apps := make(map[string]model.App, len(oldApps))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps { for _, old := range oldApps {
old.Status = constant.AppTakeDown old.Status = constant.AppTakeDown
apps[old.Key] = old apps[old.Key] = old
@@ -1316,14 +1311,12 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
app.Architectures = strings.Join(config.Architectures, ",") app.Architectures = strings.Join(config.Architectures, ",")
app.GpuSupport = config.GpuSupport app.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport app.BatchInstallSupport = config.BatchInstallSupport
if appicon.IsRemoteURL(item.Icon) { if item.Icon != "" {
app.Icon = item.Icon pendingIcons[key] = item.Icon
} else if item.Icon != "" {
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
} }
apps[key] = app apps[key] = app
} }
return apps return apps, pendingIcons
} }
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error { func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
@@ -1586,7 +1579,6 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
HttpPort: installed.HttpPort, HttpPort: installed.HttpPort,
HttpsPort: installed.HttpsPort, HttpsPort: installed.HttpsPort,
AppName: installed.App.Name, AppName: installed.App.Name,
Icon: appicon.RemoteURL(installed.App.Icon),
AppKey: installed.App.Key, AppKey: installed.App.Key,
AppType: installed.App.Type, AppType: installed.App.Type,
Path: installed.GetPath(), Path: installed.GetPath(),
+23
View File
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
backData = records[start:end] 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++ { for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name)) install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 { if install.ID > 0 {
@@ -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)
}
+5 -1
View File
@@ -163,6 +163,11 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
upgradeErrors = append(upgradeErrors, upgradeErr) upgradeErrors = append(upgradeErrors, upgradeErr)
return 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 { if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr)) t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
} }
@@ -307,7 +312,6 @@ func switchContainer(
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false) return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
} }
const ( const (
containerUpgradePullTimeout = 30 * time.Minute containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute containerUpgradeReplaceTimeout = 15 * time.Minute
-663
View File
@@ -1,663 +0,0 @@
package service
import (
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/template"
)
func LoadModelGPUDevices() dto.ModelGPUDevices {
result := dto.ModelGPUDevices{Devices: []dto.ModelGPUDevice{}, Warnings: []string{}}
uuidPattern := re.GetRegex(re.NvidiaGPUUUIDPattern)
renderPattern := re.GetRegex(re.IntelGPURenderNamePattern)
ascendPattern := re.GetRegex(re.AscendGPUDeviceNamePattern)
for _, directory := range []string{"/proc/driver/nvidia/gpus", "/sys/class/drm", "/dev"} {
entries, err := os.ReadDir(directory)
if err != nil {
if !os.IsNotExist(err) {
result.Warnings = append(result.Warnings, err.Error())
}
continue
}
for _, entry := range entries {
var device dto.ModelGPUDevice
var devicePath string
switch directory {
case "/proc/driver/nvidia/gpus":
informationPath := filepath.Join(directory, entry.Name(), "information")
content, err := os.ReadFile(informationPath)
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
continue
}
fields := map[string]string{}
for _, line := range strings.Split(string(content), "\n") {
key, value, found := strings.Cut(line, ":")
if found {
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
uuid := fields["GPU UUID"]
minor, err := strconv.Atoi(fields["Device Minor"])
if !uuidPattern.MatchString(uuid) || err != nil || minor < 0 {
result.Warnings = append(result.Warnings, fmt.Sprintf("invalid NVIDIA GPU UUID or device minor in %s", informationPath))
continue
}
name := fields["Model"]
if name == "" {
name = "NVIDIA"
}
device = dto.ModelGPUDevice{ID: "nvidia:" + uuid, Name: name + " · " + entry.Name(), Type: "nvidia", UUID: uuid}
devicePath = filepath.Join("/dev", "nvidia"+strconv.Itoa(minor))
case "/sys/class/drm":
if !renderPattern.MatchString(entry.Name()) {
continue
}
vendor, err := os.ReadFile(filepath.Join(directory, entry.Name(), "device/vendor"))
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
continue
}
if strings.TrimSpace(string(vendor)) != "0x8086" {
continue
}
devicePath = filepath.Join("/dev/dri", entry.Name())
device = dto.ModelGPUDevice{ID: "intel:" + devicePath, Name: "Intel · " + entry.Name(), Type: "intel"}
case "/dev":
if !ascendPattern.MatchString(entry.Name()) {
continue
}
devicePath = filepath.Join(directory, entry.Name())
device = dto.ModelGPUDevice{ID: "ascend:" + strings.TrimPrefix(entry.Name(), "davinci"), Name: "Ascend · " + entry.Name(), Type: "ascend"}
}
info, err := os.Stat(devicePath)
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
} else {
device.Available = info.Mode()&os.ModeCharDevice != 0
}
result.Devices = append(result.Devices, device)
}
}
return result
}
func ApplyModelGPU(service map[string]interface{}, config *dto.ModelGPUConfig, params map[string]interface{}, imageType string) error {
if config == nil {
return nil
}
if err := validateModelGPU(config, imageType); err != nil {
return err
}
resolved, err := resolveModelGPUService(service, params, false)
if err != nil {
return err
}
devicePaths := []string{}
ids := []string{}
logicalIDs := []string{}
if config.Enabled && imageType != "nvidia" {
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
if imageType == "ascend" {
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
visibleIDs := ids
if config.Mode == "all" || resolved["privileged"] == true || resolved["privileged"] == "true" {
visibleIDs = loadModelAscendDeviceIDs()
if len(visibleIDs) == 0 {
return fmt.Errorf("no available Ascend devices")
}
}
if config.Mode == "all" {
ids = visibleIDs
} else {
for _, id := range ids {
index := slices.Index(visibleIDs, id)
if index < 0 {
return fmt.Errorf("Ascend GPU is unavailable: %s", id)
}
logicalIDs = append(logicalIDs, strconv.Itoa(index))
}
}
for _, id := range ids {
devicePaths = append(devicePaths, "/dev/davinci"+id)
}
devicePaths = append(devicePaths, "/dev/davinci_manager", "/dev/devmm_svm", "/dev/hisi_hdc")
} else if config.Mode == "all" {
devicePaths = append(devicePaths, "/dev/dri")
} else {
devicePaths = append(devicePaths, ids...)
}
}
if imageType == "nvidia" {
delete(service, "gpus")
}
if imageType == "nvidia" && resolved["runtime"] == "nvidia" && !config.Enabled {
delete(service, "runtime")
}
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
devices, _ := reservations["devices"].([]interface{})
resolvedDevices, _ := resolved["reservations"].([]interface{})
kept := []interface{}{}
var gpuDevice map[string]interface{}
for index, item := range devices {
device, _ := resolvedDevices[index].(map[string]interface{})
capabilities, _ := device["capabilities"].([]interface{})
driver, _ := device["driver"].(string)
isNvidiaGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if imageType != "nvidia" || !isNvidiaGPU {
kept = append(kept, item)
} else if gpuDevice == nil {
gpuDevice, _ = item.(map[string]interface{})
}
}
if config.Enabled && imageType == "nvidia" {
device := gpuDevice
if device == nil {
device = map[string]interface{}{}
}
device["driver"] = "nvidia"
device["capabilities"] = config.Capabilities
delete(device, "count")
delete(device, "device_ids")
switch config.Mode {
case "all":
device["count"] = "all"
case "count":
device["count"] = config.Count
default:
ids := []string{}
for _, id := range config.DeviceIDs {
ids = append(ids, strings.TrimPrefix(id, "nvidia:"))
}
device["device_ids"] = ids
}
kept = append(kept, device)
}
if len(kept) > 0 {
if deploy == nil {
deploy = map[string]interface{}{}
service["deploy"] = deploy
}
if resources == nil {
resources = map[string]interface{}{}
deploy["resources"] = resources
}
if reservations == nil {
reservations = map[string]interface{}{}
resources["reservations"] = reservations
}
reservations["devices"] = kept
} else {
delete(reservations, "devices")
if len(reservations) == 0 {
delete(resources, "reservations")
}
if len(resources) == 0 {
delete(deploy, "resources")
}
if len(deploy) == 0 {
delete(service, "deploy")
}
}
mappedPaths := []string{}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
resolvedMappings, _ := resolved[key].([]interface{})
kept := []interface{}{}
for index, item := range mappings {
source := modelGPUDeviceSource(resolvedMappings[index])
managed := false
switch imageType {
case "nvidia":
managed = strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=")
case "intel":
managed = source == "/dev/dri" || strings.HasPrefix(source, "/dev/dri/")
case "ascend":
managed = strings.HasPrefix(source, "/dev/davinci") || source == "/dev/devmm_svm" || source == "/dev/hisi_hdc"
}
if managed {
selected := slices.Contains(devicePaths, source)
if config.Enabled && config.Mode == "all" && imageType == "intel" {
selected = true
}
if selected {
kept = append(kept, item)
mappedPaths = append(mappedPaths, source)
}
continue
}
kept = append(kept, item)
}
if len(kept) == 0 {
delete(service, key)
} else {
service[key] = kept
}
}
if config.Enabled && imageType != "nvidia" {
devices, _ := service["devices"].([]interface{})
for _, path := range devicePaths {
if !slices.Contains(mappedPaths, path) {
devices = append(devices, path+":"+path)
}
}
if len(devices) > 0 {
service["devices"] = devices
}
}
gpuEnvironment := map[string]string{}
if config.Enabled && imageType == "ascend" && config.Mode != "all" {
gpuEnvironment["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
var environmentKeys []string
switch imageType {
case "nvidia":
environmentKeys = []string{"NVIDIA_VISIBLE_DEVICES", "NVIDIA_DRIVER_CAPABILITIES"}
case "ascend":
environmentKeys = []string{"ASCEND_RT_VISIBLE_DEVICES"}
if params != nil {
params["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
default:
return nil
}
switch environment := service["environment"].(type) {
case map[string]interface{}:
for _, key := range environmentKeys {
delete(environment, key)
}
for key, value := range gpuEnvironment {
environment[key] = value
}
case []interface{}:
kept := []interface{}{}
for _, item := range environment {
value, ok := item.(string)
if !ok {
return fmt.Errorf("invalid Compose environment")
}
key, _, _ := strings.Cut(value, "=")
if !slices.Contains(environmentKeys, key) {
kept = append(kept, item)
}
}
for key, value := range gpuEnvironment {
kept = append(kept, key+"="+value)
}
service["environment"] = kept
case nil:
if len(gpuEnvironment) > 0 {
environment := map[string]interface{}{}
for key, value := range gpuEnvironment {
environment[key] = value
}
service["environment"] = environment
}
default:
return fmt.Errorf("invalid Compose environment")
}
return nil
}
func ReadModelGPU(service map[string]interface{}, params map[string]interface{}, imageType string) *dto.ModelGPUConfig {
resolved, err := resolveModelGPUService(service, params, true)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
if imageType == "nvidia" {
config, err := readModelGPU(service, resolved)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
return config
}
config := &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
privileged := resolved["privileged"] == true || resolved["privileged"] == "true"
all := false
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
id := ""
if imageType == "intel" {
switch {
case source == "/dev/dri":
all = true
case re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(source):
id = imageType + ":" + source
case strings.HasPrefix(source, "/dev/dri/"):
config.Error = "unsupported Intel GPU mapping requires an explicit selection: " + source
return config
}
}
if imageType == "ascend" && re.GetRegex(re.AscendGPUDevicePathPattern).MatchString(source) {
id = imageType + ":" + strings.TrimPrefix(source, "/dev/davinci")
}
if id != "" && !slices.Contains(config.DeviceIDs, id) {
config.DeviceIDs = append(config.DeviceIDs, id)
}
}
}
config.Enabled = all || len(config.DeviceIDs) > 0
config.Mode = "devices"
if all {
config.Mode = "all"
config.DeviceIDs = []string{}
}
if imageType == "ascend" {
const key = "ASCEND_RT_VISIBLE_DEVICES"
visible := ""
switch environment := service["environment"].(type) {
case map[string]interface{}:
if value, exists := environment[key]; exists {
if value == nil {
visible = "${" + key + "}"
} else {
visible = fmt.Sprint(value)
}
}
case []interface{}:
for _, item := range environment {
value, _ := item.(string)
if value == key {
visible = "${" + key + "}"
} else if strings.HasPrefix(value, key+"=") {
visible = strings.TrimPrefix(value, key+"=")
}
}
}
visible, err := resolveModelGPUValue(visible, params)
if err != nil {
config.Error = err.Error()
return config
}
visible = strings.TrimSpace(visible)
if visible == "all" || (visible == "" && config.Enabled) {
config.Enabled = true
config.Mode = "all"
config.DeviceIDs = []string{}
} else if visible != "" {
physicalIDs := []string{}
for _, id := range config.DeviceIDs {
physicalIDs = append(physicalIDs, strings.TrimPrefix(id, "ascend:"))
}
slices.SortFunc(physicalIDs, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
if privileged {
physicalIDs = loadModelAscendDeviceIDs()
}
selected := []string{}
for _, id := range strings.Split(visible, ",") {
id = strings.TrimSpace(id)
if !re.GetRegex(re.AscendGPUDeviceIDPattern).MatchString(id) {
config.Error = "invalid Ascend GPU device: " + id
return config
}
index, err := strconv.Atoi(id)
if err != nil || index < 0 || index >= len(physicalIDs) {
config.Error = "Ascend logical GPU is not available in the container: " + id
return config
}
selected = append(selected, "ascend:"+physicalIDs[index])
}
config.Enabled = true
config.Mode = "devices"
config.DeviceIDs = selected
}
}
return config
}
func readModelGPU(service, resolved map[string]interface{}) (*dto.ModelGPUConfig, error) {
config := &dto.ModelGPUConfig{Mode: "all", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
devices, _ := resolved["reservations"].([]interface{})
if service["gpus"] != nil {
return nil, fmt.Errorf("custom GPU declarations require an explicit selection")
}
for _, item := range devices {
device, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid GPU reservation")
}
driver, _ := device["driver"].(string)
capabilities, _ := device["capabilities"].([]interface{})
isGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if !isGPU {
continue
}
if config.Enabled {
return nil, fmt.Errorf("multiple GPU declarations require an explicit selection")
}
config.Enabled = true
ids, _ := device["device_ids"].([]interface{})
if len(ids) > 0 {
if device["count"] != nil {
return nil, fmt.Errorf("GPU count and device_ids cannot be combined")
}
config.Mode = "devices"
for _, id := range ids {
config.DeviceIDs = append(config.DeviceIDs, "nvidia:"+fmt.Sprint(id))
}
} else if count := device["count"]; count != nil && fmt.Sprint(count) != "all" && fmt.Sprint(count) != "-1" {
config.Mode = "count"
countValue, err := strconv.Atoi(fmt.Sprint(count))
config.Count = countValue
if err != nil {
return nil, fmt.Errorf("invalid GPU count: %v", count)
}
}
for _, capability := range capabilities {
value, ok := capability.(string)
if !ok {
return nil, fmt.Errorf("unsupported GPU capabilities: %v", capabilities)
}
if !slices.Contains(config.Capabilities, value) {
config.Capabilities = append(config.Capabilities, value)
}
}
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
if strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=") {
return nil, fmt.Errorf("direct NVIDIA device mappings require an explicit selection")
}
}
}
if !config.Enabled && resolved["runtime"] == "nvidia" {
return nil, fmt.Errorf("custom NVIDIA runtime configuration requires an explicit selection")
}
if err := validateModelGPU(config, "nvidia"); err != nil {
return nil, err
}
return config, nil
}
func validateModelGPU(config *dto.ModelGPUConfig, imageType string) error {
if config.Error != "" {
return fmt.Errorf("GPU configuration must be selected explicitly: %s", config.Error)
}
if !config.Enabled {
return nil
}
patterns := map[string]string{
"nvidia": re.NvidiaGPUDeviceIDPattern,
"intel": re.IntelGPUDevicePathPattern,
"ascend": re.AscendGPUDeviceIDPattern,
}
pattern, supported := patterns[imageType]
if !supported {
return fmt.Errorf("unsupported GPU type: %s", imageType)
}
switch config.Mode {
case "all":
if config.Count != 0 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("all GPUs cannot be combined with count or device IDs")
}
case "count":
if imageType != "nvidia" || config.Count < 1 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("GPU count requires a positive number and runtime device allocation")
}
case "devices", "":
if config.Count != 0 || len(config.DeviceIDs) == 0 {
return fmt.Errorf("select at least one GPU device")
}
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !re.GetRegex(pattern).MatchString(id) {
return fmt.Errorf("invalid %s GPU device: %s", imageType, deviceID)
}
}
default:
return fmt.Errorf("unsupported GPU selection mode: %s", config.Mode)
}
if imageType != "nvidia" {
return nil
}
if !slices.Contains(config.Capabilities, "gpu") {
return fmt.Errorf("NVIDIA capabilities must include gpu")
}
for _, capability := range config.Capabilities {
if capability != "gpu" && capability != "utility" && capability != "compute" {
return fmt.Errorf("unsupported NVIDIA capability: %s", capability)
}
}
return nil
}
func resolveModelGPUService(service map[string]interface{}, params map[string]interface{}, resolveSelection bool) (map[string]interface{}, error) {
fields := map[string]interface{}{}
fields["runtime"] = service["runtime"]
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
fields["reservations"] = reservations["devices"]
if devices, ok := reservations["devices"].([]interface{}); ok && !resolveSelection {
declarations := make([]interface{}, 0, len(devices))
for _, item := range devices {
device, _ := item.(map[string]interface{})
declarations = append(declarations, map[string]interface{}{"driver": device["driver"], "capabilities": device["capabilities"]})
}
fields["reservations"] = declarations
}
resolved, err := interpolation.Interpolate(fields, interpolation.Options{
Substitute: func(value string, _ template.Mapping) (string, error) { return resolveModelGPUValue(value, params) },
})
if err != nil {
return nil, err
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
sources := make([]interface{}, 0, len(mappings))
for _, item := range mappings {
source, err := template.SubstituteWithOptions(modelGPUDeviceSource(item), func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging)
if err != nil {
return nil, err
}
if _, short := item.(string); short {
source = modelGPUDeviceSource(source)
}
sources = append(sources, map[string]interface{}{"source": source})
}
resolved[key] = sources
}
resolved["privileged"] = service["privileged"]
if value, ok := service["privileged"].(string); ok {
if value, err := resolveModelGPUValue(value, params); err == nil {
resolved["privileged"] = value
}
}
return resolved, nil
}
func modelGPUDeviceSource(item interface{}) string {
switch mapping := item.(type) {
case string:
if strings.HasPrefix(mapping, "nvidia.com/gpu=") {
return mapping
}
depth := 0
for index, ch := range mapping {
switch ch {
case '{':
depth++
case '}':
depth--
case ':':
if depth == 0 {
return mapping[:index]
}
}
}
return mapping
case map[string]interface{}:
source, _ := mapping["source"].(string)
return source
}
return ""
}
func resolveModelGPUValue(value string, params map[string]interface{}) (string, error) {
return template.SubstituteWithOptions(value, func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging, template.WithReplacementFunction(func(value string, mapping template.Mapping, config *template.Config) (string, error) {
resolved, applied, err := template.DefaultReplacementAppliedFunc(value, mapping, config)
if err != nil {
return "", err
}
if !applied {
return "", fmt.Errorf("unresolved GPU variable: %s", value)
}
return resolved, nil
}))
}
func loadModelAscendDeviceIDs() []string {
ids := []string{}
for _, device := range LoadModelGPUDevices().Devices {
if device.Type == "ascend" && device.Available {
ids = append(ids, strings.TrimPrefix(device.ID, "ascend:"))
}
}
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
return ids
}
+1 -11
View File
@@ -8,7 +8,6 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -20,7 +19,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator" "github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/psutil" "github.com/1Panel-dev/1Panel/agent/utils/psutil"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -207,14 +205,7 @@ func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
continue continue
} }
seen[key] = true seen[key] = true
_, productName, _ := strings.Cut(device.ProductName, " - ") data.ChartHide = append(data.ChartHide, dto.GPUChartHide{DeviceID: device.DeviceID, ProductName: device.ProductName, Type: device.DeviceType, Legacy: device.DeviceID == ""})
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{
DeviceID: device.DeviceID,
ProductName: device.ProductName,
Type: device.DeviceType,
Legacy: device.DeviceID == "",
SharedMemory: strings.HasPrefix(device.DeviceID, "nvidia:") && gpu.UsesSystemMemory(productName),
})
data.Options = append(data.Options, device.ProductName) data.Options = append(data.Options, device.ProductName)
} }
return data return data
@@ -809,7 +800,6 @@ func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
}) })
for _, item := range snapshot.Devices { for _, item := range snapshot.Devices {
chartHide := dto.GPUChartHide{ chartHide := dto.GPUChartHide{
SharedMemory: item.GPU != nil && item.GPU.SharedMemory,
DeviceID: item.ID, DeviceID: item.ID,
ProductName: item.Label, ProductName: item.Label,
Type: string(item.Kind), Type: string(item.Kind),
-115
View File
@@ -1,115 +0,0 @@
package service
import (
"encoding/json"
"reflect"
"slices"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
)
func TestGetPHPRuntimeRefreshesVersionOptionsWithoutChangingConfig(t *testing.T) {
for _, resource := range []string{constant.AppResourceRemote, constant.AppResourceLocal, constant.AppResourceCustom} {
t.Run(resource, func(t *testing.T) {
db := appCacheTestDB(t)
if err := db.AutoMigrate(&model.Runtime{}); err != nil {
t.Fatal(err)
}
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: resource}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
oldVersion := dto.AppFormValue{Label: "8.1.34", Value: "8.1.34"}
newVersion := dto.AppFormValue{Label: "8.1.35", Value: "8.1.35"}
savedForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: oldVersion.Value, Values: []dto.AppFormValue{oldVersion}},
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Values: []dto.AppFormValue{{Label: "redis", Value: "redis"}}},
{EnvKey: "PANEL_APP_PORT_HTTP", Type: "number"},
{EnvKey: "CONTAINER_PACKAGE_URL", Type: "select", Values: []dto.AppFormValue{{Label: "custom mirror", Value: "https://mirror.example"}}},
}}
encode := func(form dto.AppForm) string {
t.Helper()
data, err := json.Marshal(form)
if err != nil {
t.Fatal(err)
}
return string(data)
}
detail := model.AppDetail{AppId: app.ID, Version: "8.1", Params: encode(savedForm), Update: true}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
otherMajor := model.AppDetail{AppId: app.ID, Version: "8.2", Params: `{"formFields":[{"envKey":"PHP_VERSION","values":[{"label":"8.2.31","value":"8.2.31"}]}]}`}
if err := db.Create(&otherMajor).Error; err != nil {
t.Fatal(err)
}
runtime := model.Runtime{
Name: "existing-php", Type: constant.RuntimePHP, Resource: constant.ResourceAppstore,
Version: detail.Version, AppDetailID: detail.ID, Image: "custom-php:existing", Params: detail.Params,
Env: "PHP_VERSION=8.1.34\nPHP_EXTENSIONS=redis,custom-extension\nPANEL_APP_PORT_HTTP=9070\nCONTAINER_PACKAGE_URL=https://mirror.example\nCONTAINER_NAME=my-php\nCUSTOM_SETTING=keep-me\n",
DockerCompose: "existing compose", Remark: "keep remark",
}
if err := db.Create(&runtime).Error; err != nil {
t.Fatal(err)
}
service := NewRuntimeService()
before, err := service.Get(runtime.ID)
if err != nil {
t.Fatal(err)
}
latestForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion, oldVersion}},
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Default: "opcache"},
{EnvKey: "PANEL_APP_PORT_HTTP", Default: 9000},
}}
removedForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion}},
}}
for _, tc := range []struct {
name string
params string
values []dto.AppFormValue
}{
{"new patch on reopen", encode(latestForm), []dto.AppFormValue{newVersion, oldVersion}},
{"selected version removed", encode(removedForm), []dto.AppFormValue{newVersion, oldVersion}},
{"invalid metadata", "{invalid", []dto.AppFormValue{oldVersion}},
{"missing metadata", "", []dto.AppFormValue{oldVersion}},
{"missing version field", `{"formFields":[{"envKey":"PHP_EXTENSIONS"}]}`, []dto.AppFormValue{oldVersion}},
{"empty version list", `{"formFields":[{"envKey":"PHP_VERSION","values":[]}]}`, []dto.AppFormValue{oldVersion}},
} {
t.Run(tc.name, func(t *testing.T) {
if err := db.Model(&detail).UpdateColumn("params", tc.params).Error; err != nil {
t.Fatal(err)
}
got, err := service.Get(runtime.ID)
if err != nil {
t.Fatal(err)
}
want := *before
want.AppParams = slices.Clone(before.AppParams)
want.AppParams[0].Values = tc.values
if !reflect.DeepEqual(*got, want) {
t.Fatalf("edit response changed configuration or returned stale options:\ngot: %+v\nwant: %+v", *got, want)
}
var stored model.Runtime
if err := db.First(&stored, runtime.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Params != runtime.Params || stored.Env != runtime.Env || stored.Image != runtime.Image || stored.DockerCompose != runtime.DockerCompose || stored.AppDetailID != runtime.AppDetailID || stored.Version != runtime.Version {
t.Fatal("opening edit mutated the saved runtime")
}
var storedDetail model.AppDetail
if err := db.First(&storedDetail, detail.ID).Error; err != nil {
t.Fatal(err)
}
if !storedDetail.Update {
t.Fatal("opening edit cleared the pending template update")
}
})
}
})
}
}
-34
View File
@@ -57,31 +57,12 @@ func (t TensorRTLLMService) Page(req request.TensorRTLLMSearch) response.TensorR
serverDTO.Dir = path.Join(global.Dir.TensorRTLLMDir, item.Name) serverDTO.Dir = path.Join(global.Dir.TensorRTLLMDir, item.Name)
serverDTO.Image = envs["IMAGE"] serverDTO.Image = envs["IMAGE"]
serverDTO.Command = getCommand(item.Env) serverDTO.Command = getCommand(item.Env)
var project map[string]interface{}
if yaml.Unmarshal([]byte(item.DockerCompose), &project) == nil {
services, _ := project["services"].(map[string]interface{})
if service, ok := services[item.Name].(map[string]interface{}); ok {
params := make(map[string]interface{}, len(envs))
for key, value := range envs {
params[key] = value
}
serverDTO.GPU = ReadModelGPU(service, params, "nvidia")
}
}
serverDTO.ExposedPorts, _ = loadComposeExposedPortsFromEnv(envs, "0.0.0.0", false) serverDTO.ExposedPorts, _ = loadComposeExposedPortsFromEnv(envs, "0.0.0.0", false)
composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml")) composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml"))
if err == nil { if err == nil {
serverDTO.Environments, _ = getDockerComposeEnvironments(composeByte) serverDTO.Environments, _ = getDockerComposeEnvironments(composeByte)
environments := make([]request.Environment, 0, len(serverDTO.Environments))
for _, item := range serverDTO.Environments {
if item.Key == "MODEL_PATH" || (serverDTO.GPU != nil && (item.Key == "NVIDIA_VISIBLE_DEVICES" || item.Key == "NVIDIA_DRIVER_CAPABILITIES")) {
continue
}
environments = append(environments, item)
}
serverDTO.Environments = environments
} }
volumes, err := getDockerComposeVolumes(composeByte) volumes, err := getDockerComposeVolumes(composeByte)
if err == nil { if err == nil {
@@ -152,9 +133,6 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
var environments []interface{} var environments []interface{}
environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir)) environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir))
for _, e := range create.Environments { for _, e := range create.Environments {
if e.Key == "MODEL_PATH" {
continue
}
environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value)) environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value))
} }
serviceValue["environment"] = environments serviceValue["environment"] = environments
@@ -170,18 +148,6 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
volumes = append(volumes, formatComposeVolume(volume.Source, volume.Target, volume.Mode)) volumes = append(volumes, formatComposeVolume(volume.Source, volume.Target, volume.Mode))
} }
serviceValue["volumes"] = volumes serviceValue["volumes"] = volumes
gpuParams := map[string]interface{}{}
existingEnv, err := gotenv.Unmarshal(llm.Env)
if err != nil {
return err
}
for key, value := range existingEnv {
gpuParams[key] = value
}
gpuParams["MODEL_PATH"] = create.ModelDir
if err := ApplyModelGPU(serviceValue, create.GPU, gpuParams, "nvidia"); err != nil {
return err
}
services[llm.Name] = serviceValue services[llm.Name] = serviceValue
composeByte, err := yaml.Marshal(composeMap) composeByte, err := yaml.Marshal(composeMap)
-2
View File
@@ -316,8 +316,6 @@ func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish f
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err return err
} }
return t.executeRegistered() return t.executeRegistered()
+5 -1
View File
@@ -77,7 +77,11 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err) global.LOG.Errorf("generate random hour failed: %v", err)
hourRand = big.NewInt(0) hourRand = big.NewInt(0)
} }
if _, err := global.Cron.AddJob(fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64()), job.NewAppStoreJob()); err != nil { appSyncSchedule := fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64())
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewContainerImageCheckUpdateJob()); err != nil {
global.LOG.Errorf("cannot add container image update cron job: %v", err)
}
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewAppStoreJob()); err != nil {
global.LOG.Errorf("can not add appstore corn job: %s", err.Error()) global.LOG.Errorf("can not add appstore corn job: %s", err.Error())
} }
if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil { if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil {
@@ -0,0 +1,24 @@
package job
import (
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
)
type containerImageCheckUpdate struct{}
func NewContainerImageCheckUpdateJob() *containerImageCheckUpdate {
return &containerImageCheckUpdate{}
}
func (j *containerImageCheckUpdate) Run() {
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
global.LOG.Errorf("load container image update schedule: %v", err)
return
}
if status.Enabled {
if _, err := service.StartContainerImageCheckUpdate(); err != nil {
global.LOG.Errorf("start container image update check: %v", err)
}
}
}
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained." AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "Check for image updates"
ContainerImageCheckScope: "Checking all {{ .count }} containers on this node, regardless of pagination or filters"
ContainerImageCheckItem: "Checking container {{ .name }}, image {{ .image }}"
ContainerImageCheckFailed: "Container {{ .name }} check failed: {{ .error }}; retaining previous results matching the current image"
ContainerImageCheckSkipped: "Skipping {{ .name }}: not a tagged Docker Hub image, a local image, or pinned digest"
ContainerImageCheckAvailable: "Container {{ .name }} has an update: {{ .image }} -> {{ .target }}"
ContainerImageCheckCurrent: "No update found for container {{ .name }}"
ContainerImageCheckChanged: "Container {{ .name }} was removed or its image changed; result was not saved"
ContainerImageCheckSummary: "Check finished: {{ .checked }} checked, {{ .updates }} upgradable, {{ .failed }} failed, {{ .skipped }} skipped"
ContainerImageCheckFailures: "{{ .count }} container checks failed; see the task log"
ErrImageCheckProxyRequired: "To check for image updates in the current runtime environment, please configure a proxy server first."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración co
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original." AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "Buscar actualizaciones de imágenes"
ContainerImageCheckScope: "Comprobando los {{ .count }} contenedores de este nodo, sin aplicar paginación ni filtros"
ContainerImageCheckItem: "Comprobando el contenedor {{ .name }}, imagen {{ .image }}"
ContainerImageCheckFailed: "Error al comprobar el contenedor {{ .name }}: {{ .error }}; se conservan los resultados anteriores correspondientes a la imagen actual"
ContainerImageCheckSkipped: "Se omite {{ .name }}: no es una imagen de Docker Hub con etiqueta, es una imagen local o tiene un digest fijo"
ContainerImageCheckAvailable: "El contenedor {{ .name }} tiene una actualización: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "No se encontraron actualizaciones para el contenedor {{ .name }}"
ContainerImageCheckChanged: "El contenedor {{ .name }} se eliminó o su imagen cambió; no se guardó el resultado"
ContainerImageCheckSummary: "Comprobación finalizada: comprobados {{ .checked }}, actualizables {{ .updates }}, fallidos {{ .failed }}, omitidos {{ .skipped }}"
ContainerImageCheckFailures: "Falló la comprobación de {{ .count }} contenedores; consulte el registro de la tarea"
ErrImageCheckProxyRequired: "Para comprobar actualizaciones de imágenes en el entorno actual, configura primero un servidor proxy."
+12
View File
@@ -803,3 +803,15 @@ ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کام
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد." AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "بررسی به‌روزرسانی ایمیج‌ها"
ContainerImageCheckScope: "در حال بررسی همه {{ .count }} کانتینر این گره، بدون توجه به صفحه‌بندی و فیلترها"
ContainerImageCheckItem: "در حال بررسی کانتینر {{ .name }}، ایمیج {{ .image }}"
ContainerImageCheckFailed: "بررسی کانتینر {{ .name }} ناموفق بود: {{ .error }}؛ نتایج قبلیِ منطبق با ایمیج فعلی حفظ می‌شوند"
ContainerImageCheckSkipped: "کانتینر {{ .name }} رد شد: ایمیج تگ‌دار Docker Hub نیست، ایمیج محلی است یا digest ثابت دارد"
ContainerImageCheckAvailable: "برای کانتینر {{ .name }} به‌روزرسانی موجود است: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "به‌روزرسانی برای کانتینر {{ .name }} یافت نشد"
ContainerImageCheckChanged: "کانتینر {{ .name }} حذف شده یا ایمیج آن تغییر کرده است؛ نتیجه ذخیره نشد"
ContainerImageCheckSummary: "بررسی پایان یافت: بررسی‌شده {{ .checked }}، قابل ارتقا {{ .updates }}، ناموفق {{ .failed }}، ردشده {{ .skipped }}"
ContainerImageCheckFailures: "بررسی {{ .count }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
ErrImageCheckProxyRequired: "برای بررسی به‌روزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。" AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "イメージの更新を確認"
ContainerImageCheckScope: "ページ分割やフィルターに関係なく、このノードの全 {{ .count }} コンテナを確認します"
ContainerImageCheckItem: "コンテナ {{ .name }}、イメージ {{ .image }} を確認しています"
ContainerImageCheckFailed: "コンテナ {{ .name }} の確認に失敗しました:{{ .error }}。現在のイメージに対応する前回の結果を保持します"
ContainerImageCheckSkipped: "{{ .name }} をスキップ:タグ付きの Docker Hub イメージではないか、ローカルイメージまたは digest 固定のイメージです"
ContainerImageCheckAvailable: "コンテナ {{ .name }} は更新可能です:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "コンテナ {{ .name }} の更新は見つかりませんでした"
ContainerImageCheckChanged: "コンテナ {{ .name }} が削除されたか、イメージが変更されたため、結果は保存されませんでした"
ContainerImageCheckSummary: "確認終了:成功 {{ .checked }}、更新可能 {{ .updates }}、失敗 {{ .failed }}、スキップ {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} コンテナの確認に失敗しました。タスクログを確認してください"
ErrImageCheckProxyRequired: "現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。"
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다." AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "이미지 업데이트 확인"
ContainerImageCheckScope: "페이지 및 필터와 관계없이 현재 노드의 모든 컨테이너 {{ .count }}개를 확인합니다"
ContainerImageCheckItem: "컨테이너 {{ .name }}, 이미지 {{ .image }} 확인 중"
ContainerImageCheckFailed: "컨테이너 {{ .name }} 확인 실패: {{ .error }}. 현재 이미지와 일치하는 이전 결과를 유지합니다"
ContainerImageCheckSkipped: "{{ .name }} 건너뜀: 태그가 있는 Docker Hub 이미지가 아니거나 로컬 이미지 또는 digest가 고정된 이미지입니다"
ContainerImageCheckAvailable: "컨테이너 {{ .name }} 업데이트 가능: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "컨테이너 {{ .name }}의 업데이트를 찾지 못했습니다"
ContainerImageCheckChanged: "컨테이너 {{ .name }}이 삭제되었거나 이미지가 변경되어 결과를 저장하지 않았습니다"
ContainerImageCheckSummary: "확인 종료: 성공 {{ .checked }}, 업데이트 가능 {{ .updates }}, 실패 {{ .failed }}, 건너뜀 {{ .skipped }}"
ContainerImageCheckFailures: "컨테이너 {{ .count }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
ErrImageCheckProxyRequired: "현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요."
+12
View File
@@ -793,3 +793,15 @@ ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້." AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "ກວດສອບການອັບເດດອິມເມຈ"
ContainerImageCheckScope: "ກຳລັງກວດສອບທັງໝົດ {{ .count }} ຄອນເທນເນີໃນໂນດນີ້ ໂດຍບໍ່ຂຶ້ນກັບການແບ່ງໜ້າ ຫຼື ຕົວກອງ"
ContainerImageCheckItem: "ກຳລັງກວດສອບຄອນເທນເນີ {{ .name }}, ອິມເມຈ {{ .image }}"
ContainerImageCheckFailed: "ກວດສອບຄອນເທນເນີ {{ .name }} ລົ້ມເຫຼວ: {{ .error }}; ເກັບຜົນກ່ອນໜ້າທີ່ກົງກັບອິມເມຈປັດຈຸບັນໄວ້"
ContainerImageCheckSkipped: "ຂ້າມ {{ .name }}: ບໍ່ແມ່ນອິມເມຈ Docker Hub ທີ່ມີແທັກ, ເປັນອິມເມຈພາຍໃນ ຫຼື ກຳນົດ digest ຄົງທີ່"
ContainerImageCheckAvailable: "ຄອນເທນເນີ {{ .name }} ມີອັບເດດ: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "ບໍ່ພົບອັບເດດສຳລັບຄອນເທນເນີ {{ .name }}"
ContainerImageCheckChanged: "ຄອນເທນເນີ {{ .name }} ຖືກລຶບ ຫຼື ອິມເມຈປ່ຽນແປງ; ບໍ່ໄດ້ບັນທຶກຜົນ"
ContainerImageCheckSummary: "ກວດສອບສິ້ນສຸດ: ກວດແລ້ວ {{ .checked }}, ອັບເກຣດໄດ້ {{ .updates }}, ລົ້ມເຫຼວ {{ .failed }}, ຂ້າມ {{ .skipped }}"
ContainerImageCheckFailures: "ກວດສອບ {{ .count }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
ErrImageCheckProxyRequired: "ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuh
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan." AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "Semak kemas kini imej"
ContainerImageCheckScope: "Menyemak kesemua {{ .count }} kontena pada nod ini tanpa mengira pembahagian halaman atau penapis"
ContainerImageCheckItem: "Menyemak kontena {{ .name }}, imej {{ .image }}"
ContainerImageCheckFailed: "Semakan kontena {{ .name }} gagal: {{ .error }}; keputusan terdahulu yang sepadan dengan imej semasa dikekalkan"
ContainerImageCheckSkipped: "Melangkau {{ .name }}: bukan imej Docker Hub bertag, imej setempat atau digest ditetapkan"
ContainerImageCheckAvailable: "Kontena {{ .name }} mempunyai kemas kini: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Tiada kemas kini ditemui untuk kontena {{ .name }}"
ContainerImageCheckChanged: "Kontena {{ .name }} telah dipadam atau imejnya berubah; keputusan tidak disimpan"
ContainerImageCheckSummary: "Semakan selesai: disemak {{ .checked }}, boleh dikemas kini {{ .updates }}, gagal {{ .failed }}, dilangkau {{ .skipped }}"
ContainerImageCheckFailures: "Semakan {{ .count }} kontena gagal; lihat log tugas"
ErrImageCheckProxyRequired: "Untuk menyemak kemas kini imej dalam persekitaran semasa, sila konfigurasikan pelayan proksi terlebih dahulu."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração co
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido." AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "Verificar atualizações de imagens"
ContainerImageCheckScope: "Verificando todos os {{ .count }} contêineres deste nó, independentemente da paginação e dos filtros"
ContainerImageCheckItem: "Verificando o contêiner {{ .name }}, imagem {{ .image }}"
ContainerImageCheckFailed: "Falha ao verificar o contêiner {{ .name }}: {{ .error }}; os resultados anteriores correspondentes à imagem atual serão mantidos"
ContainerImageCheckSkipped: "Ignorando {{ .name }}: não é uma imagem do Docker Hub com tag, é uma imagem local ou tem digest fixo"
ContainerImageCheckAvailable: "O contêiner {{ .name }} tem uma atualização: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Nenhuma atualização encontrada para o contêiner {{ .name }}"
ContainerImageCheckChanged: "O contêiner {{ .name }} foi removido ou sua imagem mudou; o resultado não foi salvo"
ContainerImageCheckSummary: "Verificação concluída: verificados {{ .checked }}, atualizáveis {{ .updates }}, falhas {{ .failed }}, ignorados {{ .skipped }}"
ContainerImageCheckFailures: "Falha na verificação de {{ .count }} contêineres; consulte o log da tarefa"
ErrImageCheckProxyRequired: "Para verificar atualizações de imagens no ambiente atual, configure primeiro um servidor proxy."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Эти монтирования не поддержив
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена." AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "Проверить обновления образов"
ContainerImageCheckScope: "Проверка всех контейнеров на текущем узле ({{ .count }}) независимо от страниц и фильтров"
ContainerImageCheckItem: "Проверка контейнера {{ .name }}, образ {{ .image }}"
ContainerImageCheckFailed: "Ошибка проверки контейнера {{ .name }}: {{ .error }}; сохранены предыдущие результаты, соответствующие текущему образу"
ContainerImageCheckSkipped: "Пропуск {{ .name }}: это не образ Docker Hub с тегом, локальный образ или образ с фиксированным digest"
ContainerImageCheckAvailable: "Доступно обновление контейнера {{ .name }}: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Обновлений для контейнера {{ .name }} не найдено"
ContainerImageCheckChanged: "Контейнер {{ .name }} удалён или его образ изменился; результат не сохранён"
ContainerImageCheckSummary: "Проверка завершена: проверено {{ .checked }}, доступны обновления {{ .updates }}, ошибок {{ .failed }}, пропущено {{ .skipped }}"
ContainerImageCheckFailures: "Не удалось проверить контейнеры: {{ .count }}. Подробности в журнале задачи"
ErrImageCheckProxyRequired: "Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygul
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu." AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
ContainerImageCheckUpdate: "İmaj güncellemelerini denetle"
ContainerImageCheckScope: "Sayfalama ve filtrelerden bağımsız olarak bu düğümdeki {{ .count }} konteynerin tümü denetleniyor"
ContainerImageCheckItem: "Konteyner {{ .name }}, imaj {{ .image }} denetleniyor"
ContainerImageCheckFailed: "{{ .name }} konteyneri denetlenemedi: {{ .error }}; mevcut imajla eşleşen önceki sonuçlar korunuyor"
ContainerImageCheckSkipped: "{{ .name }} atlanıyor: etiketli bir Docker Hub imajı değil, yerel bir imaj veya digest değeri sabitlenmiş"
ContainerImageCheckAvailable: "{{ .name }} konteyneri için güncelleme mevcut: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "{{ .name }} konteyneri için güncelleme bulunamadı"
ContainerImageCheckChanged: "{{ .name }} konteyneri silindi veya imajı değişti; sonuç kaydedilmedi"
ContainerImageCheckSummary: "Denetim tamamlandı: denetlenen {{ .checked }}, güncellenebilir {{ .updates }}, başarısız {{ .failed }}, atlanan {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} konteyner denetlenemedi; görev günlüğünü inceleyin"
ErrImageCheckProxyRequired: "Geçerli çalışma ortamında imaj güncellemelerini kontrol etmek için önce bir proxy sunucusu yapılandırın."
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。" AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
ContainerUpgradeWaiting: "等待容器升級任務執行名額" ContainerUpgradeWaiting: "等待容器升級任務執行名額"
ContainerImageCheckUpdate: "檢查更新映像"
ContainerImageCheckScope: "檢查目前節點全部 {{ .count }} 個容器,不受分頁和篩選影響"
ContainerImageCheckItem: "檢查容器 {{ .name }},映像 {{ .image }}"
ContainerImageCheckFailed: "容器 {{ .name }} 檢查失敗:{{ .error }};保留符合目前映像的上次結果"
ContainerImageCheckSkipped: "略過 {{ .name }}:非 Docker Hub 標籤映像、本機映像或固定 digest"
ContainerImageCheckAvailable: "容器 {{ .name }} 可升級:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "容器 {{ .name }} 未發現更新"
ContainerImageCheckChanged: "容器 {{ .name }} 已刪除或映像已變更,未儲存本次結果"
ContainerImageCheckSummary: "檢查結束:成功 {{ .checked }},可升級 {{ .updates }},失敗 {{ .failed }},略過 {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} 個容器檢查失敗,請查看任務日誌"
ErrImageCheckProxyRequired: "目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器"
+12
View File
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。" AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
ContainerUpgradeWaiting: "等待容器升级任务执行名额" ContainerUpgradeWaiting: "等待容器升级任务执行名额"
ContainerImageCheckUpdate: "检查更新镜像"
ContainerImageCheckScope: "检查当前节点全部 {{ .count }} 个容器,不受分页和筛选影响"
ContainerImageCheckItem: "检查容器 {{ .name }},镜像 {{ .image }}"
ContainerImageCheckFailed: "容器 {{ .name }} 检查失败:{{ .error }};保留匹配当前镜像的上次结果"
ContainerImageCheckSkipped: "跳过 {{ .name }}:非 Docker Hub 标签镜像、本地镜像或固定 digest"
ContainerImageCheckAvailable: "容器 {{ .name }} 可升级:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "容器 {{ .name }} 未发现更新"
ContainerImageCheckChanged: "容器 {{ .name }} 已删除或镜像发生变化,未保存本次结果"
ContainerImageCheckSummary: "检查结束:成功 {{ .checked }},可升级 {{ .updates }},失败 {{ .failed }},跳过 {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} 个容器检查失败,请查看任务日志"
ErrImageCheckProxyRequired: "当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器"
+1
View File
@@ -119,6 +119,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddQuickDirectory, migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink, migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync, migrations.AddOllamaModelAccountSync,
migrations.AddContainerImageCheckUpdates,
} }
} }
@@ -0,0 +1,17 @@
package migrations
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var AddContainerImageCheckUpdates = &gormigrate.Migration{
ID: "20261028-container-image-updates",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.ContainerImageCheckUpdate{}); err != nil {
return err
}
return tx.Where("key = ?", "ContainerImageUpdateEnabled").FirstOrCreate(&model.Setting{Key: "ContainerImageUpdateEnabled", Value: "Disable"}).Error
},
}
-1
View File
@@ -26,7 +26,6 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain) aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain)
aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo) aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo)
aiToolsRouter.GET("/model/gpu/devices", baseApi.LoadModelGPUDevices)
aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor) aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor)
aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions) aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions)
aiToolsRouter.POST("/vllm/monitor/search", baseApi.LoadVLLMMonitor) aiToolsRouter.POST("/vllm/monitor/search", baseApi.LoadVLLMMonitor)
+3
View File
@@ -16,6 +16,9 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("", baseApi.ContainerCreate) baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("/update", baseApi.ContainerUpdate) baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/upgrade", baseApi.ContainerUpgrade) baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
baRouter.GET("/image-updates", baseApi.GetContainerImageCheckUpdateStatus)
baRouter.POST("/image-updates/check", baseApi.ContainerImageCheckUpdate)
baRouter.POST("/image-updates/schedule", baseApi.SetContainerImageCheckUpdateSchedule)
baRouter.POST("/info", baseApi.ContainerInfo) baRouter.POST("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer) baRouter.POST("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer) baRouter.POST("/list", baseApi.ListContainer)
+1 -34
View File
@@ -4,12 +4,10 @@ import (
"context" "context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/shirou/gopsutil/v4/mem"
) )
const nvidiaSMICommand = "nvidia-smi" const nvidiaSMICommand = "nvidia-smi"
@@ -22,31 +20,7 @@ func (n nvidiaSMI) LoadInfo(ctx context.Context) (*Info, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("calling %s failed: %w", nvidiaSMICommand, err) return nil, fmt.Errorf("calling %s failed: %w", nvidiaSMICommand, err)
} }
info, err := parseNvidiaSMI([]byte(itemData)) return parseNvidiaSMI([]byte(itemData))
if err != nil {
return nil, err
}
if slices.ContainsFunc(info.Devices, func(device Device) bool { return device.SharedMemory }) {
memory, err := mem.VirtualMemoryWithContext(ctx)
if err != nil {
info.Warnings = append(info.Warnings, fmt.Sprintf("read system shared memory: %v", err))
} else {
for index := range info.Devices {
device := &info.Devices[index]
if !device.SharedMemory {
continue
}
device.MemUsed = fmt.Sprintf("%d B", memory.Used)
device.MemTotal = fmt.Sprintf("%d B", memory.Total)
device.MemoryFree = fmt.Sprintf("%.2f MiB", float64(memory.Available)/(1024*1024))
}
}
}
return info, nil
}
func UsesSystemMemory(productName string) bool {
return strings.EqualFold(strings.TrimSpace(productName), "NVIDIA GB10")
} }
func parseNvidiaSMI(buf []byte) (*Info, error) { func parseNvidiaSMI(buf []byte) (*Info, error) {
@@ -64,7 +38,6 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
for i := range s.Gpu { for i := range s.Gpu {
gpuItem := Device{ gpuItem := Device{
Type: "nvidia", Type: "nvidia",
SharedMemory: UsesSystemMemory(s.Gpu[i].ProductName),
MemoryActivity: s.Gpu[i].Utilization.MemoryUtil, MemoryActivity: s.Gpu[i].Utilization.MemoryUtil,
EncoderUtil: s.Gpu[i].Utilization.EncoderUtil, EncoderUtil: s.Gpu[i].Utilization.EncoderUtil,
DecoderUtil: s.Gpu[i].Utilization.DecoderUtil, DecoderUtil: s.Gpu[i].Utilization.DecoderUtil,
@@ -99,12 +72,6 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
ComputeMode: s.Gpu[i].ComputeMode, ComputeMode: s.Gpu[i].ComputeMode,
MigMode: s.Gpu[i].MigMode.CurrentMig, MigMode: s.Gpu[i].MigMode.CurrentMig,
} }
if gpuItem.SharedMemory {
gpuItem.MemUsed = "N/A"
gpuItem.MemTotal = "N/A"
gpuItem.MemoryFree = "N/A"
gpuItem.MemoryReserved = "N/A"
}
gpuItem.ECCPending = s.Gpu[i].EccMode.PendingEcc gpuItem.ECCPending = s.Gpu[i].EccMode.PendingEcc
gpuItem.ECCErrors = []ECCError{ gpuItem.ECCErrors = []ECCError{
{Scope: "Volatile Total", Correctable: s.Gpu[i].EccErrors.Volatile.SingleBit.Total, Uncorrectable: s.Gpu[i].EccErrors.Volatile.DoubleBit.Total}, {Scope: "Volatile Total", Correctable: s.Gpu[i].EccErrors.Volatile.SingleBit.Total, Uncorrectable: s.Gpu[i].EccErrors.Volatile.DoubleBit.Total},
-1
View File
@@ -17,7 +17,6 @@ type ECCError struct {
} }
type Device struct { type Device struct {
SharedMemory bool `json:"sharedMemory"`
ECCPending string `json:"eccPending"` ECCPending string `json:"eccPending"`
ECCErrors []ECCError `json:"eccErrors"` ECCErrors []ECCError `json:"eccErrors"`
MemoryActivity string `json:"memoryActivity"` MemoryActivity string `json:"memoryActivity"`
-29
View File
@@ -6,42 +6,13 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"unicode"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
const IconPrefix = "app_" const IconPrefix = "app_"
func IsRemoteURL(icon string) bool {
for _, ch := range icon {
if unicode.IsSpace(ch) || unicode.IsControl(ch) || ch == '\\' {
return false
}
}
u, err := url.Parse(icon)
if err != nil || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.Hostname() == "" || u.User != nil {
return false
}
if u.Port() != "" {
port, err := strconv.Atoi(u.Port())
if err != nil || port > 65535 {
return false
}
}
_, err = url.QueryUnescape(u.RawQuery)
return err == nil
}
func RemoteURL(icon string) string {
if IsRemoteURL(icon) {
return icon
}
return ""
}
func IsIconFile(icon string) bool { func IsIconFile(icon string) bool {
return strings.HasPrefix(icon, IconPrefix) return strings.HasPrefix(icon, IconPrefix)
} }
-37
View File
@@ -1,37 +0,0 @@
package appicon
import "testing"
func TestRemoteURLPreservesValidSources(t *testing.T) {
for _, value := range []string{
"https://cdn.example/icon.png",
"http://cdn.example:8080/icon.png",
"HTTPS://cdn.example/icon.png?token=a%2Bb&v=42#image",
"https://[::1]:8443/icon.png",
} {
t.Run(value, func(t *testing.T) {
if !IsRemoteURL(value) || RemoteURL(value) != value {
t.Fatalf("URL was rejected or rewritten: %q", value)
}
})
}
}
func TestRemoteURLRejectsOtherIconFormatsAndAmbiguousURLs(t *testing.T) {
for _, value := range []string{
"", "app_php.png?etag=42", "iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo=",
"javascript:alert(1)", "file:///icon.png", "//cdn.example/icon.png", "https:///icon.png",
"https://", "https://user:secret@cdn.example/icon.png", "https://@cdn.example/icon.png",
" https://cdn.example/icon.png", "https://cdn.example/icon.png ", "https://cdn.example/icon image.png",
"https://cdn.example/\nicon.png", "https://cdn.example/icon.png\r\nLocation: https://other.example",
"https://cdn.example/\\other.png", "https://cdn.example:bad/icon.png", "https://cdn.example/%zz",
"https://cdn.example/icon.png?token=%zz", "https://cdn.example/\u00a0icon.png", "https://cdn.example/\x7ficon.png",
"https://cdn.example:65536/icon.png",
} {
t.Run(value, func(t *testing.T) {
if IsRemoteURL(value) || RemoteURL(value) != "" {
t.Fatalf("invalid or historical icon was classified as a remote URL: %q", value)
}
})
}
}
+269
View File
@@ -0,0 +1,269 @@
// Package dockerhub checks Docker Hub metadata without downloading image layers.
package dockerhub
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/distribution/reference"
)
const registryURL = "https://registry-1.docker.io"
// Reference only accepts tagged Docker Hub references. Digest pins are intentional.
func Reference(raw string) (string, string, bool) {
if strings.HasPrefix(raw, "sha256:") {
return "", "", false
}
ref, err := reference.ParseNormalizedNamed(raw)
if err != nil || reference.Domain(ref) != "docker.io" {
return "", "", false
}
if _, pinned := ref.(reference.Digested); pinned {
return "", "", false
}
tagged := reference.TagNameOnly(ref).(reference.NamedTagged)
return reference.Path(ref), tagged.Tag(), true
}
type Client struct {
HTTP *http.Client
repository string
token string
}
type Platform struct{ OS, Architecture, Variant string }
type Target struct{ Tag, Digest, ConfigDigest string }
func New(ctx context.Context, repository, username, password string, httpClient *http.Client) (*Client, error) {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
c := &Client{HTTP: httpClient, repository: repository}
endpoint := "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if username != "" {
req.SetBasicAuth(username, password)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub authentication: HTTP %d", resp.StatusCode)
}
var body struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return nil, err
}
c.token = body.Token
if c.token == "" {
c.token = body.AccessToken
}
if c.token == "" {
return nil, fmt.Errorf("Docker Hub returned an empty token")
}
return c, nil
}
func (c *Client) get(ctx context.Context, endpoint string, result interface{}) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub metadata: HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(result)
return resp.Header, err
}
func (c *Client) Tags(ctx context.Context) ([]string, error) {
endpoint := registryURL + "/v2/" + c.repository + "/tags/list?n=1000"
var tags []string
for page := 0; page < 100; page++ {
var body struct {
Tags []string `json:"tags"`
}
headers, err := c.get(ctx, endpoint, &body)
if err != nil {
return nil, err
}
tags = append(tags, body.Tags...)
link := headers.Get("Link")
if link == "" {
return tags, nil
}
start, end := strings.Index(link, "<"), strings.Index(link, ">")
if start < 0 || end <= start {
return nil, fmt.Errorf("invalid Docker Hub pagination")
}
base, _ := url.Parse(registryURL)
next, err := base.Parse(link[start+1 : end])
if err != nil || next.Scheme != "https" || next.Host != base.Host || next.Path != "/v2/"+c.repository+"/tags/list" {
return nil, fmt.Errorf("invalid Docker Hub pagination URL")
}
endpoint = next.String()
}
return nil, fmt.Errorf("Docker Hub tag pagination limit exceeded")
}
type manifest struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
} `json:"platform"`
} `json:"manifests"`
}
func (c *Client) Resolve(ctx context.Context, tag string, platform Platform) (Target, bool, error) {
var m manifest
headers, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(tag), &m)
if err != nil {
return Target{}, false, err
}
digest := headers.Get("Docker-Content-Digest")
if len(m.Manifests) > 0 {
digest = ""
for _, item := range m.Manifests {
if item.Platform.OS == platform.OS && item.Platform.Architecture == platform.Architecture && normalizedVariant(platform.Architecture, item.Platform.Variant) == normalizedVariant(platform.Architecture, platform.Variant) {
digest = item.Digest
break
}
}
if digest == "" {
return Target{}, false, nil
}
m = manifest{}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(digest), &m); err != nil {
return Target{}, false, err
}
}
if m.Config.Digest == "" {
return Target{}, false, fmt.Errorf("unsupported Docker Hub manifest")
}
// Validate single-platform manifests too; the config digest is the Docker Image ID.
var config struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/blobs/"+url.PathEscape(m.Config.Digest), &config); err != nil {
return Target{}, false, err
}
if config.OS != platform.OS || config.Architecture != platform.Architecture || (platform.Variant != "" && config.Variant != "" && config.Variant != platform.Variant) {
return Target{}, false, nil
}
return Target{Tag: tag, Digest: digest, ConfigDigest: m.Config.Digest}, true, nil
}
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)(?:\.(\d+))?(-[a-zA-Z][a-zA-Z0-9_.-]*)?$`)
var prereleasePattern = regexp.MustCompile(`(?i)(^|[-_.])(alpha|beta|rc|dev|nightly|edge|test|unstable|preview)([0-9_.-]|$)`)
type version struct {
numbers [3]uint64
parts int
variant string
}
func parseVersion(tag string) (version, bool) {
m := versionPattern.FindStringSubmatch(tag)
if m == nil || prereleasePattern.MatchString(m[4]) {
return version{}, false
}
v := version{parts: 2, variant: m[4]}
for i := 0; i < 3; i++ {
if m[i+1] != "" {
n, err := strconv.ParseUint(m[i+1], 10, 64)
if err != nil {
return version{}, false
}
v.numbers[i] = n
}
}
if m[3] != "" {
v.parts = 3
}
return v, true
}
func greater(a, b version) bool {
for i := 0; i < 3; i++ {
if a.numbers[i] != b.numbers[i] {
return a.numbers[i] > b.numbers[i]
}
}
return false
}
// Candidates preserves the current tag's major version, variant and precision. Unknown/floating
// tags are checked only for content changes, never guessed from upload dates.
func Candidates(current string, tags []string) []string {
baseline, ok := parseVersion(current)
if !ok {
return []string{current}
}
type candidate struct {
tag string
version version
}
candidates := []candidate{}
seen := map[string]bool{}
for _, tag := range tags {
v, valid := parseVersion(tag)
if valid && v.numbers[0] == baseline.numbers[0] && v.parts == baseline.parts && v.variant == baseline.variant && greater(v, baseline) && !seen[tag] {
candidates = append(candidates, candidate{tag: tag, version: v})
seen[tag] = true
}
}
sort.Slice(candidates, func(i, j int) bool {
a, b := candidates[i], candidates[j]
if a.version.numbers == b.version.numbers {
return a.tag < b.tag
}
return greater(a.version, b.version)
})
result := make([]string, 0, len(candidates)+1)
for _, candidate := range candidates {
result = append(result, candidate.tag)
}
return append(result, current)
}
func NeedsTags(tag string) bool { _, ok := parseVersion(tag); return ok }
func normalizedVariant(architecture, variant string) string {
if architecture == "arm64" && variant == "" {
return "v8"
}
return variant
}
-14
View File
@@ -61,13 +61,6 @@ const (
AcceleratorMetricValuePattern = `^\s*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\s*(.*?)\s*$` AcceleratorMetricValuePattern = `^\s*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\s*(.*?)\s*$`
AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)` AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)`
AscendMemoryPattern = `([0-9]+(?:\.[0-9]+)?)\s*/\s*([0-9]+(?:\.[0-9]+)?)` AscendMemoryPattern = `([0-9]+(?:\.[0-9]+)?)\s*/\s*([0-9]+(?:\.[0-9]+)?)`
NvidiaGPUUUIDPattern = `^GPU-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
NvidiaGPUDeviceIDPattern = `^([0-9]+|GPU-[a-zA-Z0-9-]+|MIG-[a-zA-Z0-9/-]+)$`
IntelGPURenderNamePattern = `^renderD[0-9]+$`
IntelGPUDevicePathPattern = `^/dev/dri/renderD[0-9]+$`
AscendGPUDeviceNamePattern = `^davinci[0-9]+$`
AscendGPUDevicePathPattern = `^/dev/davinci[0-9]+$`
AscendGPUDeviceIDPattern = `^[0-9]+$`
) )
var regexMap = make(map[string]*regexp.Regexp) var regexMap = make(map[string]*regexp.Regexp)
@@ -129,13 +122,6 @@ func Init() {
AcceleratorMetricValuePattern, AcceleratorMetricValuePattern,
AscendVersionPattern, AscendVersionPattern,
AscendMemoryPattern, AscendMemoryPattern,
NvidiaGPUUUIDPattern,
NvidiaGPUDeviceIDPattern,
IntelGPURenderNamePattern,
IntelGPUDevicePathPattern,
AscendGPUDeviceNamePattern,
AscendGPUDevicePathPattern,
AscendGPUDeviceIDPattern,
} }
for _, pattern := range patterns { for _, pattern := range patterns {
+68
View File
@@ -7,13 +7,17 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
) )
func HandleGet(url string) (*http.Response, error) { func HandleGet(url string) (*http.Response, error) {
@@ -162,3 +166,67 @@ func loadRequestTransport() *http.Transport {
IdleConnTimeout: 15 * time.Second, IdleConnTimeout: 15 * time.Second,
} }
} }
var ErrProxyRequired = errors.New("runtime environment requires a configured proxy")
// LoadRuntimeProxyURL reads and validates local configuration without creating an HTTP client.
// A nil URL preserves the default network behavior in the global environment.
func LoadRuntimeProxyURL() (*url.URL, error) {
var rows []model.Setting
if err := global.DB.Where("key IN ?", []string{"Edition", "ProxyType", "ProxyUrl", "ProxyPort", "ProxyUser", "ProxyPasswd"}).Find(&rows).Error; err != nil {
return nil, err
}
settings := make(map[string]string, len(rows))
for _, row := range rows {
settings[row.Key] = row.Value
}
if settings["Edition"] == "intl" {
return nil, nil
}
kind, host, port := settings["ProxyType"], strings.TrimSpace(settings["ProxyUrl"]), settings["ProxyPort"]
if kind == "" || kind == "close" || host == "" || port == "" {
return nil, ErrProxyRequired
}
if kind != "http" && kind != "https" && kind != "socks5" {
return nil, ErrProxyRequired
}
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return nil, ErrProxyRequired
}
if !strings.Contains(host, "://") {
host = kind + "://" + host
}
address, err := url.Parse(host)
if err != nil || address.Scheme != kind || address.Hostname() == "" || address.User != nil || address.RawQuery != "" || address.Fragment != "" || (address.Path != "" && address.Path != "/") {
return nil, ErrProxyRequired
}
address.Host = net.JoinHostPort(address.Hostname(), port)
address.Path = ""
if settings["ProxyUser"] != "" {
password, err := encrypt.StringDecrypt(settings["ProxyPasswd"])
if err != nil {
return nil, ErrProxyRequired
}
address.User = url.UserPassword(settings["ProxyUser"], password)
}
return address, nil
}
// NewRuntimeHTTPClient builds a client for requests using the validated runtime proxy configuration.
func NewRuntimeHTTPClient() (*http.Client, error) {
address, err := LoadRuntimeProxyURL()
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
if address == nil {
return client, nil
}
transport := http.DefaultTransport.(*http.Transport).Clone()
// Explicit proxy selection prevents NO_PROXY from bypassing the panel proxy.
// net/http also supports SOCKS5 URLs and proxy authentication.
transport.Proxy = http.ProxyURL(address)
client.Transport = transport
return client, nil
}
+2 -144
View File
@@ -15,7 +15,7 @@ import (
) )
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeyManagement(c); err != nil { if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil) helper.BadAuth(c, "ErrNotLogin", nil)
return false return false
} }
@@ -41,24 +41,6 @@ func apiKeyError(c *gin.Context, err error) {
helper.InternalServer(c, err) helper.InternalServer(c, err)
} }
// Swag's legacy || syntax combines schemes in one requirement (AND);
// separate @Security lines represent alternative authentication methods.
// @Tags API Key
// @Summary List the current account's API keys
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
// @Accept json
// @Produce json
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/search [post]
func (b *BaseApi) SearchAPIKeys(c *gin.Context) { func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -73,25 +55,6 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Create an API key for the current account
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/create [post]
func (b *BaseApi) CreateAPIKey(c *gin.Context) { func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}} req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -106,22 +69,6 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Update an API key's policy
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/update [post]
func (b *BaseApi) UpdateAPIKey(c *gin.Context) { func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -134,21 +81,6 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Enable or disable an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/status [post]
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) { func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -161,22 +93,6 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Revoke an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/revoke [post]
func (b *BaseApi) RevokeAPIKey(c *gin.Context) { func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -190,63 +106,5 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
} }
func apiKeyMutationSuccess(c *gin.Context) { func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")}) helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
// @Tags API Key
// @Summary Delete an independent API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/delete [post]
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Reveal an independent API key's plaintext secret
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/reveal [post]
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return
}
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Reveal(c, req)
if err != nil {
apiKeyError(c, err)
return
}
helper.SuccessWithData(c, result)
} }
+8 -11
View File
@@ -417,13 +417,11 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Generate or reset the legacy API key // @Summary generate api key
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
// @Accept json // @Accept json
// @Produce json // @Success 200 {string} key
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500" // @Security ApiKeyAuth
// @Header 200 {string} Cache-Control "no-store" // @Security Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/generate [post] // @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) { func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -442,13 +440,12 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Update the legacy API key configuration // @Summary Update api config
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
// @Accept json // @Accept json
// @Produce json
// @Param request body dto.ApiInterfaceConfig true "request" // @Param request body dto.ApiInterfaceConfig true "request"
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500" // @Success 200
// @Security PanelSession || CSRFToken // @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/auth/api/update [post] // @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"} // @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) { func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+1 -16
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net" "net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -43,7 +42,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0] ownerProvider = ownerProviders[0]
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) { if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
c.Next() c.Next()
return return
} }
@@ -98,20 +97,6 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
} }
} }
func isAPIKeyManagementRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
return true
default:
return false
}
}
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) { func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
settingRepo := repo.NewISettingRepo() settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{} config := APIAuthConfig{}
+3 -23
View File
@@ -66,26 +66,6 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
return u, nil return u, nil
} }
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
if c == nil || !c.GetBool("API_AUTH") {
return RequireAPIKeySession(c)
}
if c.GetBool("LOCAL_REQUEST") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
value, _ := c.Get(psession.GinContextSessionUserKey)
u, ok := value.(psession.SessionUser)
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
return u, nil
}
func HasAPICredentials(c *gin.Context) bool { func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != "" return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
} }
@@ -107,7 +87,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
} }
func APIKeySecret(key model.APIKey) (string, error) { func APIKeySecret(key model.APIKey) (string, error) {
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" { if key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid") return "", buserr.New("ErrApiConfigKeyInvalid")
} }
master, err := encrypt.APIKeyEncryptionKey() master, err := encrypt.APIKeyEncryptionKey()
@@ -118,7 +98,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
} }
func ValidateAPIKeyState(key model.APIKey, now time.Time) error { func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.DeletedAt != nil || key.Status != constant.StatusEnable { if key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid") return buserr.New("ErrApiConfigStatusInvalid")
} }
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) { if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
@@ -133,7 +113,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) { func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID") keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision") q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" { if keyID != "" {
q = q.Where("id = ?", keyID) q = q.Where("id = ?", keyID)
} else { } else {
+27 -35
View File
@@ -6,42 +6,41 @@ import (
) )
type APIKeyItem struct { type APIKeyItem struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"` ID string `json:"id"`
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"` Kind string `json:"kind"`
Name string `json:"name" example:"Deployment automation"` Name string `json:"name"`
Description string `json:"description" example:"Used by the deployment service"` Description string `json:"description"`
KeyHint string `json:"keyHint" example:"abcd••••1234"` KeyHint string `json:"keyHint"`
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"` Status string `json:"status"`
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"` IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"` APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"` APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"` ExpiresAt *time.Time `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding" example:"false"` AllowAppBinding bool `json:"allowAppBinding"`
Revision uint64 `json:"revision" minimum:"1" example:"1"` Revision uint64 `json:"revision"`
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"` CreatedAt *time.Time `json:"createdAt"`
} }
type APIKeySearch struct { type APIKeySearch struct {
Page int `json:"page" minimum:"1" example:"1"` Page int `json:"page"`
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"` PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked" default:"false"` ExcludeRevoked bool `json:"excludeRevoked"`
} }
type APIKeyPage struct { type APIKeyPage struct {
Items []APIKeyItem `json:"items"` Items []APIKeyItem `json:"items"`
Total int `json:"total"` Total int `json:"total"`
} }
type APIKeyFields struct { type APIKeyFields struct {
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"` Name string `json:"name"`
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"` Description string `json:"description"`
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"` IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"` APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"` APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"` ExpiresAt json.RawMessage `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding" default:"false"` AllowAppBinding bool `json:"allowAppBinding"`
} }
type APIKeyCreate struct { type APIKeyCreate struct {
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server. RequestID string `json:"requestID"`
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
APIKeyFields APIKeyFields
} }
type APIKeyCreated struct { type APIKeyCreated struct {
@@ -50,15 +49,8 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"` AlreadyCreated bool `json:"alreadyCreated,omitempty"`
} }
type APIKeyMutation struct { type APIKeyMutation struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"` ID string `json:"id"`
Revision uint64 `json:"revision" minimum:"1" example:"1"` Revision uint64 `json:"revision"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
} }
type APIKeyUpdate struct { type APIKeyUpdate struct {
APIKeyMutation APIKeyMutation
@@ -66,5 +58,5 @@ type APIKeyUpdate struct {
} }
type APIKeyStatus struct { type APIKeyStatus struct {
APIKeyMutation APIKeyMutation
Status string `json:"status" enums:"Enable,Disable" example:"Disable"` Status string `json:"status"`
} }
+1
View File
@@ -83,6 +83,7 @@ type SettingBaseInfo struct {
ComplexityVerification string `json:"complexityVerification"` ComplexityVerification string `json:"complexityVerification"`
NoAuthSetting string `json:"noAuthSetting"` NoAuthSetting string `json:"noAuthSetting"`
ProxyType string `json:"proxyType"` ProxyType string `json:"proxyType"`
ProxyEnabled bool `json:"proxyEnabled"`
ScriptSync string `json:"scriptSync"` ScriptSync string `json:"scriptSync"`
-1
View File
@@ -24,7 +24,6 @@ type APIKey struct {
CreatedAt time.Time `json:"-"` CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"` RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
} }
type LegacyAPIKeyPolicy struct { type LegacyAPIKeyPolicy struct {
+2 -2
View File
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
} }
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) { func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey var key model.APIKey
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error err := global.DB.Where("id = ?", id).First(&key).Error
return key, err return key, err
} }
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) { func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0) items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL") q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
if excludeRevoked { if excludeRevoked {
q = q.Where("status <> ?", "Revoked") q = q.Where("status <> ?", "Revoked")
} }
+9 -89
View File
@@ -52,27 +52,6 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
return s.Provider.CurrentAPIKeyOwner(c) return s.Provider.CurrentAPIKeyOwner(c)
} }
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
if !c.GetBool("API_AUTH") {
return s.Provider.CurrentAPIKeyOwner(c)
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
if err != nil {
return auth.APIKeyOwner{}, err
}
if owner.Type != ownerType || owner.ID != ownerID {
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return owner, nil
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem { func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
@@ -96,7 +75,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
} }
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) { func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -167,13 +146,11 @@ func keyHint(secret string) string {
} }
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) { func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if req.RequestID == "" { if _, err = uuid.Parse(req.RequestID); err != nil {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams") return nil, buserr.New("ErrInvalidParams")
} }
expires, err := validateAPIKeyFields(&req.APIKeyFields, true) expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
@@ -195,9 +172,6 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID) q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil { if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
@@ -245,7 +219,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
} }
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -273,7 +247,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
} }
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -297,7 +271,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
} }
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error { func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -315,75 +289,21 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
}) })
} }
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return buserr.New("ErrInvalidParams")
}
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
if key.RevokedAt == nil {
key.RevokedAt = &now
}
key.DeletedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.KeyHint = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
return nil, buserr.New("ErrInvalidParams")
}
// Serialize the snapshot and disclosure audit with revocation/deletion.
apiKeyMutationMu.Lock()
defer apiKeyMutationMu.Unlock()
var key model.APIKey
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrAPIKeyNotFound")
}
return nil, err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return nil, buserr.New("ErrAPIKeyConflict")
}
secret, err := auth.APIKeySecret(key)
if err != nil {
return nil, buserr.New("ErrApiConfigKeyInvalid")
}
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
return nil, err
}
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error { func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 { if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams") return buserr.New("ErrInvalidParams")
} }
apiKeyMutationMu.Lock() apiKeyMutationMu.Lock()
var key model.APIKey var key model.APIKey
closeTerminals := action == "disable" || action == "revoke" || action == "delete" closeTerminals := action == "disable" || action == "revoke"
err := global.DB.Transaction(func(tx *gorm.DB) error { err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil { if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound") return buserr.New("ErrAPIKeyNotFound")
} }
return err return err
} }
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") { if key.Revision != req.Revision || key.Status == "Revoked" {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
before := key before := key
+2
View File
@@ -179,6 +179,8 @@ func (u *SettingService) GetSettingBaseInfo() (*dto.SettingBaseInfo, error) {
_ = settingRepo.UpdateOrCreate("MenuAccordion", info.MenuAccordion) _ = settingRepo.UpdateOrCreate("MenuAccordion", info.MenuAccordion)
} }
info.ProxyEnabled = info.ProxyType == "http" || info.ProxyType == "https" || info.ProxyType == "socks5"
return &info, err return &info, err
} }
+262 -2175
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -4542,9 +4542,6 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"302": {
"description": "redirect to the official icon URL"
} }
}, },
"security": [ "security": [
@@ -5342,17 +5339,6 @@
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/request.AppUpdateVersion"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -47491,23 +47477,6 @@
], ],
"type": "object" "type": "object"
}, },
"request.AppUpdateVersion": {
"properties": {
"appInstallID": {
"type": "integer"
},
"loadCompose": {
"type": "boolean"
},
"updateVersion": {
"type": "string"
}
},
"required": [
"appInstallID"
],
"type": "object"
},
"response.AppConfig": { "response.AppConfig": {
"properties": { "properties": {
"advanced": { "advanced": {
@@ -47534,9 +47503,6 @@
"hostMode": { "hostMode": {
"type": "boolean" "type": "boolean"
}, },
"isEdit": {
"type": "boolean"
},
"memoryLimit": { "memoryLimit": {
"type": "number" "type": "number"
}, },
@@ -47797,9 +47763,6 @@
"gpuSupport": { "gpuSupport": {
"type": "boolean" "type": "boolean"
}, },
"icon": {
"type": "string"
},
"id": { "id": {
"type": "integer" "type": "integer"
}, },
+2 -5
View File
@@ -18,10 +18,7 @@ import (
// @schemes http https // @schemes http https
// @securityDefinitions.apikey ApiKeyAuth // @securityDefinitions.apikey ApiKeyAuth
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token. // @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
// @description ``` // @description ```
// @description eg: // @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \ // @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -30,7 +27,7 @@ import (
// @description ``` // @description ```
// @description - `1Panel-Token` is the key for the panel API Key. // @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey // @type apiKey
// @in header // @in Header
// @name 1Panel-Token // @name 1Panel-Token
// @securityDefinitions.apikey Timestamp // @securityDefinitions.apikey Timestamp
// @type apiKey // @type apiKey
-3
View File
@@ -159,9 +159,6 @@ var WebUrlMap = map[string]struct{}{
"/websites": {}, "/websites": {},
"/websites/templates": {}, "/websites/templates": {},
"/websites/ssl": {}, "/websites/ssl": {},
"/websites/ssl/ca": {},
"/websites/ssl/acme-account": {},
"/websites/ssl/dns-account": {},
"/websites/runtimes/php": {}, "/websites/runtimes/php": {},
"/websites/runtimes/node": {}, "/websites/runtimes/node": {},
"/websites/runtimes/java": {}, "/websites/runtimes/java": {},
-1
View File
@@ -72,6 +72,5 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrder, migrations.UpdateAiMenuOrder,
migrations.UpdateAiMenuOrderV2, migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP, migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
} }
} }
@@ -15,8 +15,3 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key", ID: "20260915-add-operation-log-api-key",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) }, Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
} }
var AddAPIKeyDeletedAt = &gormigrate.Migration{
ID: "20261028-api-key-deleted-at",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
}
-34
View File
@@ -1,34 +0,0 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type iconRedirectTransport func(*http.Request) (*http.Response, error)
func (f iconRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestLocalAppIconRedirectIsPassedToBrowser(t *testing.T) {
const icon = "https://cdn.example/icon.png?signature=a%2Bb&v=42"
calls := 0
proxy := newLocalAgentProxy(iconRedirectTransport(func(req *http.Request) (*http.Response, error) {
calls++
if req.URL.Host != "unix" || req.URL.Path != "/api/v2/apps/icon/php" {
t.Fatalf("proxy attempted to fetch a remote image: %s", req.URL)
}
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": {icon}, "Cache-Control": {"no-cache"}},
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
rw := httptest.NewRecorder()
proxy.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php", nil))
if rw.Code != http.StatusFound || rw.Header().Get("Location") != icon || rw.Header().Get("Cache-Control") != "no-cache" || calls != 1 {
t.Fatalf("local proxy did not preserve the redirect: status=%d headers=%v calls=%d", rw.Code, rw.Header(), calls)
}
}
-4
View File
@@ -42,10 +42,6 @@ func OperationLog() gin.HandlerFunc {
return return
} }
switch c.Request.URL.Path { switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/reveal":
// Disclosure has its own audit and must not enter response-body capture.
c.Next()
return
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export": case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
c.Next() c.Next()
return return
-2
View File
@@ -40,8 +40,6 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey) authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus) authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey) authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
authRouter.POST("/api/keys/delete", baseApi.DeleteAPIKey)
authRouter.POST("/api/keys/reveal", baseApi.RevealAPIKey)
authRouter.GET("/current", baseApi.GetCurrentUser) authRouter.GET("/current", baseApi.GetCurrentUser)
authRouter.POST("/current/update", baseApi.UpdateCurrentUser) authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
-13
View File
@@ -45,8 +45,6 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
"enable": {"启用 API Key", "Enable API key"}, "enable": {"启用 API Key", "Enable API key"},
"disable": {"停用 API Key", "Disable API key"}, "disable": {"停用 API Key", "Disable API key"},
"revoke": {"撤销 API Key", "Revoke API key"}, "revoke": {"撤销 API Key", "Revoke API key"},
"delete": {"删除 API Key", "Delete API key"},
"reveal": {"查看 API Key", "Reveal API key"},
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"}, "app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
} }
label, ok := labels[event.Action] label, ok := labels[event.Action]
@@ -82,17 +80,6 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
record.UserAgent = c.Request.UserAgent() record.UserAgent = c.Request.UserAgent()
record.IP = c.ClientIP() record.IP = c.ClientIP()
} }
if c.GetBool("API_AUTH") && event.Action != "app_binding" {
record.AuthMethod = "api_key"
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
}
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
record.IP = clientIP
}
}
} }
if event.ClientIP != "" { if event.ClientIP != "" {
record.IP = event.ClientIP record.IP = event.ClientIP
-18
View File
@@ -1,21 +1,6 @@
import { ReqPage } from '.'; import { ReqPage } from '.';
export namespace AI { export namespace AI {
export interface ModelGPUDevice {
id: string;
name: string;
type: string;
uuid?: string;
available: boolean;
}
export interface ModelGPUConfig {
mode: 'all' | 'count' | 'devices';
count?: number;
error?: string;
enabled: boolean;
deviceIDs: string[];
capabilities: string[];
}
export interface ModelAccountSync { export interface ModelAccountSync {
id: number; id: number;
modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom'; modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom';
@@ -49,7 +34,6 @@ export namespace AI {
xpu: XpuInfo['xpu']; xpu: XpuInfo['xpu'];
} }
export interface GPU { export interface GPU {
sharedMemory?: boolean;
eccPending?: string; eccPending?: string;
eccErrors?: { scope: string; correctable: string; uncorrectable: string }[]; eccErrors?: { scope: string; correctable: string; uncorrectable: string }[];
memoryActivity?: string; memoryActivity?: string;
@@ -140,7 +124,6 @@ export namespace AI {
chartHide: Array<ChartHide>; chartHide: Array<ChartHide>;
} }
export interface ChartHide { export interface ChartHide {
sharedMemory?: boolean;
deviceID?: string; deviceID?: string;
legacy?: boolean; legacy?: boolean;
productName: string; productName: string;
@@ -409,7 +392,6 @@ export namespace AI {
} }
export interface TensorRTLLM { export interface TensorRTLLM {
gpu?: ModelGPUConfig;
id?: number; id?: number;
name: string; name: string;
containerName: string; containerName: string;
-3
View File
@@ -30,7 +30,4 @@ export namespace APIKey {
id: string; id: string;
revision: number; revision: number;
} }
export interface Revealed extends Reference {
apiKey: string;
}
} }
-6
View File
@@ -53,7 +53,6 @@ export namespace App {
export interface AppItem { export interface AppItem {
name: string; name: string;
icon?: string;
key: string; key: string;
id: number; id: number;
description: string; description: string;
@@ -138,7 +137,6 @@ export namespace App {
} }
export interface AppInstall { export interface AppInstall {
gpu?: AI.ModelGPUConfig;
syncModelAccount?: boolean; syncModelAccount?: boolean;
modelAccountBaseURLType?: string; modelAccountBaseURLType?: string;
modelAccountBaseURL?: string; modelAccountBaseURL?: string;
@@ -293,7 +291,6 @@ export namespace App {
feishuConfigured?: boolean; feishuConfigured?: boolean;
version: string; version: string;
detailId: number; detailId: number;
dockerCompose?: string;
} }
export interface InstallParams { export interface InstallParams {
@@ -313,7 +310,6 @@ export namespace App {
} }
export interface AppConfig { export interface AppConfig {
gpu?: AI.ModelGPUConfig;
appKey: string; appKey: string;
name: string; name: string;
httpPort: number; httpPort: number;
@@ -322,7 +318,6 @@ export namespace App {
modelAccountBaseURL: string; modelAccountBaseURL: string;
params: InstallParams[]; params: InstallParams[];
rawCompose?: string; rawCompose?: string;
isEdit?: boolean;
cpuQuota: number; cpuQuota: number;
memoryLimit: number; memoryLimit: number;
memoryUnit: string; memoryUnit: string;
@@ -346,7 +341,6 @@ export namespace App {
export interface AppUpdateVersionReq { export interface AppUpdateVersionReq {
appInstallID: number; appInstallID: number;
updateVersion?: string; updateVersion?: string;
loadCompose?: boolean;
} }
export interface AppIgnoreReq { export interface AppIgnoreReq {
+3
View File
@@ -136,6 +136,9 @@ export namespace Container {
mode: string; mode: string;
} }
export interface ContainerInfo { export interface ContainerInfo {
latestVersion?: string;
targetImage?: string;
hasUpdate?: boolean;
containerID: string; containerID: string;
name: string; name: string;
imageName: string; imageName: string;
-1
View File
@@ -151,7 +151,6 @@ export namespace Dashboard {
inodesUsedPercent: number; inodesUsedPercent: number;
} }
export interface GPUInfo { export interface GPUInfo {
sharedMemory?: boolean;
type: string; type: string;
index: number; index: number;
npuIndex: number; npuIndex: number;
+1
View File
@@ -97,6 +97,7 @@ export namespace Setting {
complexityVerification: string; complexityVerification: string;
noAuthSetting: string; noAuthSetting: string;
proxyType: string; proxyType: string;
proxyEnabled: boolean;
scriptSync: string; scriptSync: string;
dashboardMemoVisible: string; dashboardMemoVisible: string;
-4
View File
@@ -43,10 +43,6 @@ export const updateBindDomain = (req: AI.BindDomain) => {
return http.post(`/ai/domain/update`, req); return http.post(`/ai/domain/update`, req);
}; };
export const loadModelGPUDevices = () => {
return http.get<{ devices: AI.ModelGPUDevice[]; warnings: string[] }>('/ai/model/gpu/devices');
};
export const loadGPUInfo = () => { export const loadGPUInfo = () => {
return http.get<AI.Info>(`/ai/gpu/load`); return http.get<AI.Info>(`/ai/gpu/load`);
}; };
+1 -7
View File
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => { export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
return http.post<APIKey.Search>(`${prefix}/search`, params); return http.post<APIKey.Search>(`${prefix}/search`, params);
}; };
export const createAPIKey = (params: APIKey.Editable & { requestID?: string }) => { export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => {
return http.post<APIKey.Created>(`${prefix}/create`, params); return http.post<APIKey.Created>(`${prefix}/create`, params);
}; };
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => { export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
@@ -18,9 +18,3 @@ export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' |
export const revokeAPIKey = (params: APIKey.Reference) => { export const revokeAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params); return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
}; };
export const deleteAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/delete`, params);
};
export const revealAPIKey = (params: APIKey.Reference) => {
return http.post<APIKey.Revealed>(`${prefix}/reveal`, params);
};
+5 -6
View File
@@ -2,7 +2,6 @@ import http from '@/api';
import { ResPage } from '../interface'; import { ResPage } from '../interface';
import { App } from '../interface/app'; import { App } from '../interface/app';
import { TimeoutEnum } from '@/enums/http-enum'; import { TimeoutEnum } from '@/enums/http-enum';
import { resolveAppIconUrl } from '@/utils/app-icon';
export const syncApp = (req: App.AppStoreSync) => { export const syncApp = (req: App.AppStoreSync) => {
return http.post('apps/sync/remote', req); return http.post('apps/sync/remote', req);
@@ -100,9 +99,8 @@ export const getAppDefaultConfig = (key: string, name: string) => {
return http.post<string>(`apps/installed/conf`, { type: key, name: name }); return http.post<string>(`apps/installed/conf`, { type: key, name: name });
}; };
export const getAppInstallParams = (id: number, node?: string) => { export const getAppInstallParams = (id: number) => {
const params = node ? `?operateNode=${encodeURIComponent(node)}` : ''; return http.get<App.AppConfig>(`apps/installed/params/${id}`);
return http.get<App.AppConfig>(`apps/installed/params/${id}${params}`);
}; };
export const updateAppInstallParams = (req: any) => { export const updateAppInstallParams = (req: any) => {
@@ -137,9 +135,10 @@ export const getCurrentNodeCustomAppConfig = () => {
return http.get<App.CustomAppStoreConfig>(`/custom/app/config`); return http.get<App.CustomAppStoreConfig>(`/custom/app/config`);
}; };
export function getAppIconUrl(appKey: string, node?: string, icon = ''): string { export function getAppIconUrl(appKey: string, node?: string): string {
const baseURL = import.meta.env.VITE_API_URL as string; const baseURL = import.meta.env.VITE_API_URL as string;
return resolveAppIconUrl(appKey, node, icon, baseURL); const params = node ? `?operateNode=${node}` : '';
return `${baseURL}/apps/icon/${appKey}${params}`;
} }
export const installAppToNodes = (param: App.InstallAppToNodes) => { export const installAppToNodes = (param: App.InstallAppToNodes) => {
+30
View File
@@ -290,3 +290,33 @@ export const updateIpv6Option = (fixedCidrV6: string, ip6Tables: boolean, experi
export const updateDaemonJsonByfile = (params: Container.DaemonJsonUpdateByFile) => { export const updateDaemonJsonByfile = (params: Container.DaemonJsonUpdateByFile) => {
return http.post(`/containers/daemonjson/update/byfile`, params, TimeoutEnum.T_3M); return http.post(`/containers/daemonjson/update/byfile`, params, TimeoutEnum.T_3M);
}; };
export interface ContainerImageCheckUpdateStatus {
taskID: string;
enabled: boolean;
running: boolean;
checked: number;
updates: number;
failed: number;
skipped: number;
error: string;
finishedAt: number;
}
export const getContainerImageCheckUpdateStatus = (node: string, signal?: AbortSignal) =>
http.get<ContainerImageCheckUpdateStatus>(
'/containers/image-updates',
{},
{ headers: { CurrentNode: node }, signal },
);
export const runContainerImageCheckUpdate = (node: string) =>
http.postWithConfig<ContainerImageCheckUpdateStatus>(
'/containers/image-updates/check',
{},
{ headers: { CurrentNode: node } },
);
export const setContainerImageCheckUpdateSchedule = (enabled: boolean, node: string) =>
http.postWithConfig<ContainerImageCheckUpdateStatus>(
'/containers/image-updates/schedule',
{ enabled },
{ headers: { CurrentNode: node } },
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 424 KiB

@@ -1,5 +1,5 @@
<template> <template>
<el-form-item v-if="showToggle" prop="advanced"> <el-form-item prop="advanced">
<el-checkbox v-model="form.advanced" :label="$t('app.advanced')" size="large" /> <el-checkbox v-model="form.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item> </el-form-item>
<div v-if="form.advanced"> <div v-if="form.advanced">
@@ -83,7 +83,6 @@ const props = withDefaults(
editCompose: boolean; editCompose: boolean;
dockerCompose: string; dockerCompose: string;
}; };
showToggle?: boolean;
showAllowPort?: boolean; showAllowPort?: boolean;
showSpecifyIP?: boolean; showSpecifyIP?: boolean;
showContainerName?: boolean; showContainerName?: boolean;
@@ -94,7 +93,6 @@ const props = withDefaults(
autoLoadLimit?: boolean; autoLoadLimit?: boolean;
}>(), }>(),
{ {
showToggle: true,
showAllowPort: true, showAllowPort: true,
showSpecifyIP: true, showSpecifyIP: true,
showContainerName: true, showContainerName: true,
@@ -6,14 +6,27 @@
:confirm-before-close="true" :confirm-before-close="true"
@before-close="beforeClose" @before-close="beforeClose"
> >
<el-form <template v-if="created">
ref="formRef" <el-alert
:model="form" :title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
:rules="rules" :type="secret ? 'warning' : 'info'"
label-position="top" :closable="false"
class="text-[var(--el-text-color-regular)]" show-icon
@submit.prevent="submit" class="mb-4"
> />
<el-form label-position="top">
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
<el-input :model-value="secret" readonly autocomplete="off">
<template #append>
<CopyButton :content="secret" :is-icon="false" />
</template>
</el-input>
</el-form-item>
</el-form>
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
</template>
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4"> <el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6"> <ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
<li> <li>
@@ -126,10 +139,13 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button :disabled="busy" @click="beforeClose(() => (visible = false))"> <el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))">
{{ $t('commons.button.cancel') }} {{ $t('commons.button.cancel') }}
</el-button> </el-button>
<el-button type="primary" :loading="busy" @click="submit"> <el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
{{ $t('commons.button.confirm') }}
</el-button>
<el-button v-else type="primary" :loading="busy" @click="submit">
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }} {{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
</el-button> </el-button>
</template> </template>
@@ -138,7 +154,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
import type { FormInstance } from 'element-plus'; import { ElMessageBox, type FormInstance } from 'element-plus';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
@@ -158,6 +174,9 @@ const visible = ref(false);
const busy = ref(false); const busy = ref(false);
const uncertain = ref(false); const uncertain = ref(false);
const item = ref<APIKey.Item>(); const item = ref<APIKey.Item>();
const created = ref<APIKey.Item>();
const secret = ref('');
const saved = ref(false);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const ipMode = ref('restricted'); const ipMode = ref('restricted');
const neverExpires = ref(false); const neverExpires = ref(false);
@@ -177,6 +196,8 @@ let editorVersion = 0;
const discardEditor = () => { const discardEditor = () => {
editorVersion++; editorVersion++;
secret.value = '';
created.value = undefined;
visible.value = false; visible.value = false;
busy.value = false; busy.value = false;
}; };
@@ -219,6 +240,9 @@ const rules = {
const open = (existing?: APIKey.Item, fromApp = false) => { const open = (existing?: APIKey.Item, fromApp = false) => {
editorVersion++; editorVersion++;
item.value = existing; item.value = existing;
created.value = undefined;
secret.value = '';
saved.value = false;
uncertain.value = false; uncertain.value = false;
pendingCreate = undefined; pendingCreate = undefined;
requestID = uuidv4(); requestID = uuidv4();
@@ -245,9 +269,19 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
nextTick(() => formRef.value?.clearValidate()); nextTick(() => formRef.value?.clearValidate());
}; };
const beforeClose = (done: () => void) => { const finish = () => {
secret.value = '';
visible.value = false;
if (created.value) emit('created', created.value);
};
const beforeClose = async (done: () => void) => {
if (busy.value) return; if (busy.value) return;
if (uncertain.value) emit('changed'); if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return;
}
if (created.value) finish();
else if (uncertain.value) emit('changed');
done(); done();
}; };
@@ -276,13 +310,9 @@ const submit = async () => {
pendingCreate ??= { ...payload, requestID }; pendingCreate ??= { ...payload, requestID };
const response = await createAPIKey(pendingCreate); const response = await createAPIKey(pendingCreate);
if (ticket !== editorVersion) return; if (ticket !== editorVersion) return;
created.value = response.data.item;
secret.value = response.data.apiKey;
uncertain.value = false; uncertain.value = false;
pendingCreate = undefined;
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
visible.value = false;
emit('changed');
emit('created', response.data.item);
return;
} }
emit('changed'); emit('changed');
} catch (error) { } catch (error) {
@@ -311,12 +341,16 @@ watch(
{ flush: 'sync' }, { flush: 'sync' },
); );
onBeforeUnmount(discardEditor); onBeforeUnmount(discardEditor);
onBeforeRouteLeave((to) => { onBeforeRouteLeave(async (to) => {
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) { if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
discardEditor(); discardEditor();
return true; return true;
} }
if (busy.value) return false; if (busy.value) return false;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return false;
}
discardEditor(); discardEditor();
return true; return true;
}); });
+2 -2
View File
@@ -11,14 +11,14 @@
<el-col :span="22"> <el-col :span="22">
<el-alert class="mt-2" :show-icon="true" type="warning" :closable="false"> <el-alert class="mt-2" :show-icon="true" type="warning" :closable="false">
<div v-for="(item, index) in form.msgs" :key="index"> <div v-for="(item, index) in form.msgs" :key="index">
<div class="dialog-object-name" style="line-height: 20px"> <div style="line-height: 20px; word-wrap: break-word">
<span>{{ item }}</span> <span>{{ item }}</span>
</div> </div>
</div> </div>
</el-alert> </el-alert>
<slot name="content"></slot> <slot name="content"></slot>
<ul v-for="(item, index) in form.names" :key="index"> <ul v-for="(item, index) in form.names" :key="index">
<div class="dialog-object-name"> <div style="word-wrap: break-word">
<li>{{ item }}</li> <li>{{ item }}</li>
</div> </div>
</ul> </ul>
+1 -1
View File
@@ -59,7 +59,7 @@
class="mr-2 flex-shrink-0" class="mr-2 flex-shrink-0"
></svg-icon> ></svg-icon>
<el-tooltip :content="item.path" placement="top"> <el-tooltip :content="item.path" placement="top">
<span class="dialog-object-name text-sm">{{ item.name }}</span> <span class="truncate text-sm">{{ item.name }}</span>
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
@@ -13,14 +13,16 @@
@click.stop="handleButtonClick(button)" @click.stop="handleButtonClick(button)"
> >
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon> <el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
<el-badge is-dot :hidden="!hasDot(button)">
<span>{{ button.label }}</span> <span>{{ button.label }}</span>
</el-badge>
</el-button> </el-button>
</span> </span>
<span v-if="actions.more.length" class="fu-table-operations__action"> <span v-if="actions.more.length" class="fu-table-operations__action">
<el-dropdown class="fu-table-more-button" :trigger="trigger" @command="handleButtonClick"> <el-dropdown class="fu-table-more-button" :trigger="trigger" @command="handleButtonClick">
<span class="fu-table-operations__dropdown-trigger"> <span class="fu-table-operations__dropdown-trigger">
<el-button class="fu-table-operations__button" link type="primary"> <el-button class="fu-table-operations__button" link type="primary">
{{ t('fu.table.more') }} <el-badge is-dot :hidden="!actions.more.some(hasDot)">{{ t('fu.table.more') }}</el-badge>
</el-button> </el-button>
</span> </span>
<template #dropdown> <template #dropdown>
@@ -33,7 +35,9 @@
:divided="button.divided" :divided="button.divided"
> >
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon> <el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
<el-badge is-dot :hidden="!hasDot(button)">
<span>{{ button.label }}</span> <span>{{ button.label }}</span>
</el-badge>
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -75,6 +79,9 @@ const actions = computed(() => {
}; };
}); });
const hasDot = (button: FuTableOperationButton) =>
typeof button.dot === 'function' ? button.dot(props.row) : Boolean(button.dot);
const handleButtonClick = (button: FuTableOperationButton) => { const handleButtonClick = (button: FuTableOperationButton) => {
if (!isOperationDisabled(button, props.row)) { if (!isOperationDisabled(button, props.row)) {
button.click?.(props.row); button.click?.(props.row);
+1
View File
@@ -23,6 +23,7 @@ export interface FuTableOperationButton<Row = any> {
type?: string; type?: string;
icon?: any; icon?: any;
divided?: boolean; divided?: boolean;
dot?: boolean | ((row: Row) => boolean);
} }
export const FU_TABLE_STORAGE_PREFIX = 'FU-T-'; export const FU_TABLE_STORAGE_PREFIX = 'FU-T-';
+24 -31
View File
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Show secret',
hideSecret: 'Hide secret',
revealHelp: 'Keep this secret secure. Disabled or expired keys remain unusable even when displayed.',
deleteConfirm:
'Delete “{0}”? Its secret will be destroyed and the key removed from the list. New requests will be rejected and terminal connections closed. Audit records are retained; running jobs are not cancelled. This cannot be undone.',
title: 'API Key', title: 'API Key',
manage: 'Manage API Keys', manage: 'Manage API Keys',
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.', personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
@@ -28,6 +23,10 @@ const message = {
allowAppBinding: 'Allow APP QR binding', allowAppBinding: 'Allow APP QR binding',
bindingDisableHelp: bindingDisableHelp:
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.', 'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
saved: 'I have saved the secret',
alreadyCreated:
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
uncertain: uncertain:
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.', 'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
legacyHelp: legacyHelp:
@@ -1037,23 +1036,6 @@ const message = {
validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges', validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges',
}, },
model: { model: {
gpuAcceleration: 'GPU acceleration',
gpuMode: 'Accelerator selection',
gpuAll: 'All accelerators',
gpuCount: 'Specify count',
gpuSpecific: 'Select devices',
gpuAllHelper: 'Use all available accelerators of this type on the current node.',
gpuCountHelper:
'Let the runtime allocate the requested number of accelerators without binding specific devices.',
gpuSpecificHelper: 'Manually select specific accelerators.',
gpuCountRequired: 'Enter an integer greater than 0',
gpuDevices: 'GPU devices',
gpuCapabilitiesHelper:
'GPU access is required; enable monitoring and management (utility) and computing support (compute) as needed.',
gpuSelectRequired: 'Select at least one available GPU device.',
gpuUnsupported: 'Unsupported',
gpuNotDetected: 'No supported accelerators detected',
gpuMissing: 'Unavailable',
modelCategory: 'Model category', modelCategory: 'Model category',
modelName: 'Model name', modelName: 'Model name',
modelArchitecture: 'Model type', modelArchitecture: 'Model type',
@@ -1071,11 +1053,11 @@ const message = {
onlineModel: 'Online models', onlineModel: 'Online models',
instanceDeployment: 'Deploy by instance', instanceDeployment: 'Deploy by instance',
nvidiaDeployment: 'NVIDIA-optimized deployment', nvidiaDeployment: 'NVIDIA-optimized deployment',
engines: 'Local inference engines', engines: 'Inference engines',
modelManagement: 'Model management', modelManagement: 'Model management',
serviceDetails: 'Service details', serviceDetails: 'Service details',
deployments: 'Local deployments', deployments: 'Local deployments',
files: 'Model hub', files: 'Model files',
connectExternal: 'Connect external service', connectExternal: 'Connect external service',
deploymentTemplates: 'vLLM startup command templates', deploymentTemplates: 'vLLM startup command templates',
engineProvider: 'Engine / Provider', engineProvider: 'Engine / Provider',
@@ -1643,11 +1625,6 @@ const message = {
timePerOutputTokenChart: 'Inter-token latency', timePerOutputTokenChart: 'Inter-token latency',
}, },
gpu: { gpu: {
systemMemory: 'Shared system memory',
systemMemoryUsed: 'System memory used',
systemMemoryTotal: 'Total system memory',
systemMemoryAvailable: 'Available system memory',
systemMemoryHelper: 'Shared by the CPU and GPU. Shows system-wide memory usage, not GPU-only usage.',
memoryActivity: 'Memory read/write activity', memoryActivity: 'Memory read/write activity',
hotspotTemperature: 'Hotspot temperature', hotspotTemperature: 'Hotspot temperature',
fanRPM: 'Fan speed (RPM)', fanRPM: 'Fan speed (RPM)',
@@ -1683,6 +1660,7 @@ const message = {
historyPeak: 'Peak', historyPeak: 'Peak',
historyAggregated: historyAggregated:
'{count} samples aggregated into {seconds}-second intervals; processes show counts only.', '{count} samples aggregated into {seconds}-second intervals; processes show counts only.',
historyRaw: '{count} original samples.',
historyMetricEmpty: 'No data for this metric in the selected time range', historyMetricEmpty: 'No data for this metric in the selected time range',
historyLoadFailed: 'Failed to load history. Please retry.', historyLoadFailed: 'Failed to load history. Please retry.',
collectedAt: 'Collected at', collectedAt: 'Collected at',
@@ -1755,8 +1733,8 @@ const message = {
prohibitedHelper: 'Prohibited: Concurrent process execution is not allowed', prohibitedHelper: 'Prohibited: Concurrent process execution is not allowed',
migModeHelper: 'Used to create MIG instances, implementing physical GPU isolation at the user layer.', migModeHelper: 'Used to create MIG instances, implementing physical GPU isolation at the user layer.',
migModeNA: 'Not Supported', migModeNA: 'Not Supported',
current: 'Real-time Status', current: 'Real-time Monitoring',
history: 'Monitoring Data', history: 'Historical Records',
notSupport: 'The current version or driver does not support displaying this parameter.', notSupport: 'The current version or driver does not support displaying this parameter.',
}, },
mcp: { mcp: {
@@ -2064,6 +2042,8 @@ const message = {
retry: 'Retry selected points', retry: 'Retry selected points',
environment: 'Environment', environment: 'Environment',
plan: 'Frozen configuration', plan: 'Frozen configuration',
clientHelp:
'The client container runs on the main node and sends requests to an existing service. It does not start inference or require a GPU.',
retryHelp: 'Only selected abnormal points are retried, with the same parameters and new seeds.', retryHelp: 'Only selected abnormal points are retried, with the same parameters and new seeds.',
secretHelp: 'API keys and headers are excluded from copies. Enter them again if needed.', secretHelp: 'API keys and headers are excluded from copies. Enter them again if needed.',
resultHelp: resultHelp:
@@ -2136,6 +2116,19 @@ const message = {
batchUpgradeWaiting: 'Queued', batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading', batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted', batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired:
'To check for image updates in the current runtime environment, please configure a proxy server first.',
imageCheckUpdateConfigureProxy: 'Configure',
imageCheckUpdateAvailableVersion: 'Available version “{version}”',
imageCheckUpdateContentChanged: '(image content changed)',
imageCheckUpdates: 'Check for image updates',
imageCheckUpdateNow: 'Check now',
imageCheckUpdateSchedule: 'Scheduled checks',
imageCheckUpdateDisable: 'Disable scheduled checks',
imageCheckUpdateStarted: 'Checking images used by all containers on this node',
imageCheckUpdateResult:
'Check complete: {checked} checked, {updates} upgradable, {failed} failed, {skipped} skipped',
createByCommand: 'Create by command', createByCommand: 'Create by command',
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported', importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported', importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',

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