Compare commits

..
170 changed files with 3342 additions and 8872 deletions
+2 -9
View File
@@ -207,7 +207,6 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
// @Accept json
// @Param key path string true "app key"
// @Success 200 {file} file "app icon"
// @Success 302 "redirect to the official icon URL"
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/icon/:key [get]
@@ -217,18 +216,12 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
helper.BadRequest(c, err)
return
}
iconBytes, source, etag, err := appService.GetAppIcon(appKey)
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
if err != nil {
helper.InternalServer(c, err)
return
}
if appicon.IsRemoteURL(source) {
c.Header("Cache-Control", "no-cache")
c.Redirect(http.StatusFound, source)
return
}
if len(iconBytes) == 0 {
c.Status(http.StatusNoContent)
return
@@ -236,7 +229,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" && source != "" {
if etag != "" && filename != "" {
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
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
// @Summary Search app update version by install id
// @Accept json
// @Param request body request.AppUpdateVersion true "request"
// @Success 200 {array} dto.AppVersion
// @Security ApiKeyAuth
// @Security Timestamp
@@ -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 (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
helper.SuccessWithData(c, service.LoadModelGPUDevices())
}
// @Tags AI
// @Summary Load gpu / xpu info
// @Accept json
-3
View File
@@ -318,7 +318,6 @@ type AgentAccountModelDiscoverReq struct {
}
type AgentAccountModelCreateReq struct {
TaskID string `json:"taskID"`
AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"`
}
@@ -329,7 +328,6 @@ type AgentAccountModelUpdateReq struct {
}
type AgentAccountModelDeleteReq struct {
DeleteFiles bool `json:"deleteFiles"`
AccountID uint `json:"accountId" validate:"required"`
RecordID uint `json:"recordId" validate:"required"`
}
@@ -360,7 +358,6 @@ type AgentAccountUpdateReq struct {
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
Models []AgentAccountModel `json:"models"`
}
type AgentAccountVerifyReq struct {
-22
View File
@@ -2,28 +2,6 @@ package dto
import "time"
type ModelGPUDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
UUID string `json:"uuid,omitempty"`
Available bool `json:"available"`
}
type ModelGPUDevices struct {
Devices []ModelGPUDevice `json:"devices"`
Warnings []string `json:"warnings"`
}
type ModelGPUConfig struct {
Mode string `json:"mode"`
Count int `json:"count,omitempty"`
Error string `json:"error,omitempty"`
Enabled bool `json:"enabled"`
DeviceIDs []string `json:"deviceIDs"`
Capabilities []string `json:"capabilities"`
}
type OllamaModelInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
+3
View File
@@ -21,6 +21,9 @@ type InspectReq struct {
}
type ContainerInfo struct {
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
-1
View File
@@ -177,7 +177,6 @@ type DiskInfo struct {
}
type GPUInfo struct {
SharedMemory bool `json:"sharedMemory"`
Type string `json:"type"`
Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"`
-1
View File
@@ -56,7 +56,6 @@ type MonitorGPUOptions struct {
Options []string `json:"options"`
}
type GPUChartHide struct {
SharedMemory bool `json:"sharedMemory"`
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"`
-5
View File
@@ -22,9 +22,6 @@ type AppInstallCreate struct {
Name string `json:"name" validate:"required"`
Services map[string]string `json:"services"`
TaskID string `json:"taskID"`
GPU *dto.ModelGPUConfig `json:"gpu"`
GPUImageType string `json:"-"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
@@ -131,7 +128,6 @@ type AppInstallDelete struct {
}
type AppInstalledUpdate struct {
GPU *dto.ModelGPUConfig `json:"gpu"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
@@ -160,7 +156,6 @@ type PortUpdate struct {
type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"`
LoadCompose *bool `json:"loadCompose,omitempty"`
}
type AppInstallSortItem struct {
-1
View File
@@ -16,7 +16,6 @@ type TensorRTLLMCreate struct {
Command string `json:"command" validate:"required"`
ModelType string `json:"modelType"`
ModelSpeedup bool `json:"modelSpeedup"`
GPU *dto.ModelGPUConfig `json:"gpu"`
DockerConfig
}
-3
View File
@@ -31,7 +31,6 @@ type AppDTO struct {
type AppItem struct {
Name string `json:"name"`
Key string `json:"key"`
Icon string `json:"icon,omitempty"`
ID uint `json:"id"`
Description string `json:"description"`
Status string `json:"status"`
@@ -187,7 +186,6 @@ type AppParam struct {
}
type AppConfig struct {
GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
AppKey string `json:"appKey"`
Name string `json:"name"`
HttpPort int `json:"httpPort"`
@@ -196,6 +194,5 @@ type AppConfig struct {
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"`
IsEdit bool `json:"isEdit"`
request.AppContainerConfig
}
-2
View File
@@ -1,7 +1,6 @@
package response
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
@@ -19,7 +18,6 @@ type TensorRTLLMDTO struct {
ModelDir string `json:"modelDir"`
Image string `json:"image"`
Command string `json:"command"`
GPU *dto.ModelGPUConfig `json:"gpu"`
ExposedPorts []request.ExposedPort `json:"exposedPorts"`
Environments []request.Environment `json:"environments"`
@@ -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" }
+2 -9
View File
@@ -18,9 +18,8 @@ type IAgentAccountRepo interface {
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
Count() (int64, error)
CountTextByProviders(providers []string) (map[string]int64, error)
SaveWithModels(account *model.AgentAccount, models []model.AgentAccountModel) error
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -70,12 +69,6 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
return accounts, nil
}
func (a AgentAccountRepo) Count() (int64, error) {
var count int64
err := getDb().Model(&model.AgentAccount{}).Count(&count).Error
return count, err
}
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
normalizedProviders := normalizeProviders(providers)
counts := make(map[string]int64, len(normalizedProviders))
@@ -106,7 +99,7 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil
}
func (a AgentAccountRepo) SaveWithModels(account *model.AgentAccount, models []model.AgentAccountModel) error {
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
-6
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -31,7 +30,6 @@ type IAppRepo interface {
BatchCreate(ctx context.Context, apps []model.App) error
Create(ctx context.Context, app *model.App) error
Save(ctx context.Context, app *model.App) error
UpdateIcon(id uint, icon string) error
BatchDelete(ctx context.Context, apps []model.App) error
DeleteByIDs(ctx context.Context, ids []uint) error
DeleteBy(opts ...DBOption) error
@@ -43,10 +41,6 @@ func NewIAppRepo() IAppRepo {
return &AppRepo{}
}
func (a AppRepo) UpdateIcon(id uint, icon string) error {
return getDb(WithByID(id), a.WithResource(constant.AppResourceRemote)).Model(&model.App{}).UpdateColumn("icon", icon).Error
}
func (a AppRepo) WithByLikeName(name string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(name) == 0 {
-16
View File
@@ -17,8 +17,6 @@ type IAppDetailRepo interface {
WithIgnored() DBOption
GetFirst(opts ...DBOption) (model.AppDetail, error)
Update(ctx context.Context, detail model.AppDetail) error
CacheCompose(detail model.AppDetail, compose string) (bool, error)
MarkDownloaded(detail model.AppDetail) (bool, error)
BatchCreate(ctx context.Context, details []model.AppDetail) error
DeleteByAppIds(ctx context.Context, appIds []uint) error
DeleteByIDs(ctx context.Context, appIds []uint) error
@@ -59,20 +57,6 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) error
return getTx(ctx).Save(&detail).Error
}
func (a AppDetailRepo) CacheCompose(detail model.AppDetail, compose string) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("docker_compose", compose)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) MarkDownloaded(detail model.AppDetail) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("update", false)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
}
+1 -1
View File
@@ -86,10 +86,10 @@ func (u *LauncherRepo) ListQuickJump(withAll bool) []model.QuickJump {
}
if !withAll && len(quicks) == 0 {
return []model.QuickJump{
{Name: "ModelServices", Title: "aiTools.model.model", Recommend: 0, IsShow: true, Router: "/ai/model"},
{Name: "Agent", Title: "aiTools.agents.agent", Recommend: 1, IsShow: true, Router: "/ai/agents/agent"},
{Name: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
{Name: "Database", Title: "menu.database", Recommend: 30, IsShow: true, Router: "/databases"},
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: false, Router: "/cronjobs"},
{Name: "AppInstalled", Title: "home.appInstalled", Recommend: 70, IsShow: true, Router: "/apps/installed"},
}
}
+11 -101
View File
@@ -1105,24 +1105,10 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if err != nil {
return err
}
if req.Models != nil {
if account.AppInstallID != 0 {
return buserr.WithDetail("ErrInvalidParams", "local model pool is managed by the deployment", nil)
}
models, err = normalizeAgentAccountModels(account, req.Models)
if err != nil {
return err
}
if err := ensureAccountModelsNotBound(account, models); err != nil {
return err
}
}
requestedVerifyModel := req.VerifyModel
if strings.TrimSpace(requestedVerifyModel) == "" {
if _, ok := findAgentAccountModelForProvider(provider, models, account.VerifyModel); ok {
requestedVerifyModel = account.VerifyModel
}
}
verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
@@ -1145,16 +1131,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark
account.Verified = true
if req.Models != nil {
rows := make([]model.AgentAccountModel, 0, len(models))
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
}
err = agentAccountRepo.SaveWithModels(account, rows)
} else {
err = agentAccountRepo.Save(account)
}
if err != nil {
if err := agentAccountRepo.Save(account); err != nil {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
@@ -1298,31 +1275,6 @@ func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) err
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
return buserr.New("ErrRecordExist")
}
if account.Provider == "ollama" {
if account.AppInstallID != 0 {
install, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err != nil {
return err
}
if install.App.Key != "ollama" || !install.SyncModelAccount {
return buserr.New("ErrAgentAccountUnavailable")
}
localModels, err := aiRepo.List()
if err != nil {
return err
}
for _, item := range localModels {
if sameProviderModelID("ollama", item.Name, nextModel.ID) {
return (&AIToolService{}).Recreate(dto.OllamaModelName{Name: item.Name, TaskID: req.TaskID})
}
}
return (&AIToolService{}).Create(dto.OllamaModelName{Name: nextModel.ID, TaskID: req.TaskID})
}
nextModel.ID, err = resolveExistingOllamaModel(account, nextModel.ID)
if err != nil {
return err
}
}
sortOrder := len(models) + 1
record := &model.AgentAccountModel{
AccountID: account.ID,
@@ -1353,15 +1305,6 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err != nil {
return err
}
if account.Provider == "ollama" && !sameProviderModelID("ollama", record.Model, nextModel.ID) {
if account.AppInstallID != 0 {
return buserr.New("ErrInvalidParams")
}
nextModel.ID, err = resolveExistingOllamaModel(account, nextModel.ID)
if err != nil {
return err
}
}
nextModel.RecordID = req.Model.RecordID
for _, item := range models {
if item.RecordID == req.Model.RecordID {
@@ -1400,10 +1343,6 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
}
func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
if err != nil {
return err
@@ -1429,44 +1368,6 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
return err
}
if req.DeleteFiles && account.Provider != "ollama" {
return buserr.New("ErrAgentProviderNotSupported")
}
if req.DeleteFiles {
if account.AppInstallID != 0 {
install, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err != nil {
return err
}
if install.App.Key != "ollama" {
return buserr.New("ErrAgentAccountUnavailable")
}
localModels, err := aiRepo.List()
if err != nil {
return err
}
localIDs := make([]uint, 0)
for _, item := range localModels {
if !sameProviderModelID("ollama", item.Name, record.Model) {
continue
}
if item.Status == constant.StatusWaiting {
return buserr.New("TaskIsExecuting")
}
localIDs = append(localIDs, item.ID)
}
if err := cmd.NewCommandMgr().Run("docker", "exec", install.ContainerName, "ollama", "rm", record.Model); err != nil {
return err
}
if len(localIDs) > 0 {
if err := aiRepo.Delete(repo.WithByIDs(localIDs)); err != nil {
return err
}
}
} else if err := deleteRemoteOllamaModel(account, record.Model); err != nil {
return err
}
}
if err := agentAccountModelRepo.DeleteByID(req.RecordID); err != nil {
return err
}
@@ -1493,10 +1394,19 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
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 {
var agentCount int64
+1 -99
View File
@@ -1,19 +1,13 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
@@ -94,7 +88,7 @@ func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL str
}
}
if modelsChanged {
err = agentAccountRepo.SaveWithModels(account, rows)
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
@@ -142,62 +136,6 @@ func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBase
return err
}
func updateOllamaModelPool(modelName string, remove bool) error {
install, err := appInstallRepo.LoadInstallAppByKeyAndName("ollama", "")
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if !install.SyncModelAccount {
return nil
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
models, err := loadAgentAccountModels(account)
if err != nil {
return err
}
if remove {
for _, item := range models {
if !sameProviderModelID("ollama", item.ID, modelName) {
continue
}
if err := agentAccountModelRepo.DeleteByID(item.RecordID); err != nil {
return err
}
}
if sameProviderModelID("ollama", account.VerifyModel, modelName) {
account.VerifyModel = ""
if err := agentAccountRepo.Save(account); err != nil {
return err
}
}
if err := compactPersistedAgentAccountModelSortOrder(account.ID); err != nil {
return err
}
} else {
if _, exists := findAgentAccountModelForProvider("ollama", models, modelName); exists {
return nil
}
record := &model.AgentAccountModel{AccountID: account.ID, Model: modelName, Name: modelName, SortOrder: len(models) + 1}
if err := agentAccountModelRepo.Create(record); err != nil {
return err
}
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return nil
}
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
addressType := install.ModelAccountBaseURLType
if addressType == "" {
@@ -262,39 +200,3 @@ func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, cont
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
func deleteRemoteOllamaModel(account *model.AgentAccount, modelName string) error {
body, err := json.Marshal(map[string]string{"model": modelName})
if err != nil {
return err
}
baseURL := strings.TrimSuffix(strings.TrimRight(account.BaseURL, "/"), "/v1")
req, err := http.NewRequest(http.MethodDelete, baseURL+"/api/delete", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+account.APIKey)
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("delete Ollama model failed: %s", resp.Status)
}
return nil
}
func resolveExistingOllamaModel(account *model.AgentAccount, modelID string) (string, error) {
available, err := providercatalog.DiscoverModels(account.BaseURL, account.APIKey)
if err != nil {
return "", buserr.WithErr("ErrAgentAccountUnavailable", err)
}
for _, id := range available {
if sameProviderModelID("ollama", id, modelID) {
return id, nil
}
}
return "", buserr.New("ErrRecordNotFound")
}
-8
View File
@@ -1517,14 +1517,6 @@ func sameProviderModelID(provider, left, right string) bool {
}
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
if provider == "ollama" {
if leftComparable != "" && !strings.Contains(path.Base(leftComparable), ":") {
leftComparable += ":latest"
}
if rightComparable != "" && !strings.Contains(path.Base(rightComparable), ":") {
rightComparable += ":latest"
}
}
return leftComparable != "" && leftComparable == rightComparable
}
+18 -24
View File
@@ -117,7 +117,6 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
}
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, info.ID)
if err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
return err
}
@@ -127,17 +126,19 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(info.Name, containerName)
if err != nil {
return err
if len(itemSize) != 0 {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
} else {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
return aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
return updateOllamaModelPool(info.Name, false)
return nil
}, nil)
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -166,9 +167,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if modelInfo.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
if modelInfo.Status == constant.StatusWaiting {
return buserr.New("TaskIsExecuting")
}
containerName, err := LoadContainerName()
if err != nil {
return err
@@ -178,7 +176,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}
taskItem, err := task.NewTaskWithOps(fmt.Sprintf("ollama-model-%s", req.Name), task.TaskPull, task.TaskScopeAI, req.TaskID, modelInfo.ID)
if err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
global.LOG.Errorf("new task for exec shell failed, err: %v", err)
return err
}
@@ -188,17 +185,19 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(modelInfo.Name, containerName)
if err != nil {
return err
if len(itemSize) != 0 {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
} else {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
return aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusSuccess, "size": itemSize})
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("TaskSync"), func(t *task.Task) error {
return updateOllamaModelPool(modelInfo.Name, false)
return nil
}, nil)
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -220,16 +219,11 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
return fmt.Errorf("handle ollama rm %s failed, %v", item.Name, err)
}
}
if err := aiRepo.Delete(repo.WithByID(item.ID)); err != nil {
return err
}
if err := updateOllamaModelPool(item.Name, true); err != nil {
return err
}
_ = aiRepo.Delete(repo.WithByID(item.ID))
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem)
}
return nil
return SyncOllamaModelAccount(false)
}
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
+71 -63
View File
@@ -135,7 +135,6 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
ID: ap.ID,
Name: ap.Name,
Key: ap.Key,
Icon: appicon.RemoteURL(ap.Icon),
Limit: ap.Limit,
GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend,
@@ -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) {
return appDetailDTO, err
}
packageReady := err == nil
if packageReady && app.Resource == constant.AppResourceRemote {
if err == nil && app.Resource == constant.AppResourceRemote {
detail.Update = false
appDetailDTO.Update = false
}
if packageReady && strings.TrimSpace(detail.DockerCompose) == "" {
compose, err := fileOp.GetContent(filepath.Join(versionPath, "docker-compose.yml"))
if err != nil {
return appDetailDTO, err
}
if err := cacheAppDetailCompose(&detail, compose); err != nil {
return appDetailDTO, err
}
}
switch app.Type {
case constant.RuntimePHP:
paramMap := make(map[string]interface{})
@@ -307,19 +296,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil {
return appDetailDTO, err
}
if err := validateAppDetailCompose(compose); err != nil {
return appDetailDTO, err
}
var composeMap struct {
Services map[string]interface{} `yaml:"services"`
}
composeMap := make(map[string]interface{})
if err := yaml.Unmarshal(compose, &composeMap); err != nil {
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
}
}
}
} else {
paramMap := make(map[string]interface{})
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
}
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
}
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)
@@ -349,13 +355,6 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
if err != nil {
return nil, err
}
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return nil, err
}
if err := ensureAppDetailCompose(app, &appDetail); err != nil {
return nil, err
}
res.AppDetail = appDetail
paramMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil {
@@ -443,14 +442,32 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
App: app,
}
composeMap := make(map[string]interface{})
var composeRes []byte
if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return
}
} else {
if 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
}
}
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 {
return
}
@@ -525,26 +542,6 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
composeByte []byte
paramByte []byte
)
if (app.Key == "ollama" || app.Key == "vllm") && req.GPU != nil {
gpuServiceName := appInstall.ServiceName
imageType := "nvidia"
if app.Key == "vllm" {
imageType = req.GPUImageType
if len(servicesMap) > 1 {
gpuServiceName = "vllm"
if _, ok := servicesMap[gpuServiceName]; !ok {
gpuServiceName = "vllm-dspark"
}
}
}
service, ok := servicesMap[gpuServiceName].(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
if err = ApplyModelGPU(service, req.GPU, req.Params, imageType); err != nil {
return
}
}
composeByte, err = yaml.Marshal(composeMap)
if err != nil {
@@ -925,10 +922,22 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res.CanUpdate = true
return res, err
}
list, err := readAppList()
if err != nil || list.LastModified != lastModified {
list, err = getAppList()
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
for _, app := range apps {
if app.Icon == "" {
res.CanUpdate = true
return res, err
}
if appicon.IsIconFile(app.Icon) {
fileName, _ := appicon.ParseIconField(app.Icon)
if fileName == "" || !appicon.IconFileExists(fileName) {
res.CanUpdate = true
return res, err
}
}
}
list, err := getAppList()
if err != nil {
return res, err
}
@@ -959,26 +968,29 @@ func getAppFromRepo(downloadPath string) error {
}
func getAppList() (*dto.AppList, error) {
list := &dto.AppList{}
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
return nil, err
}
return readAppList()
}
func readAppList() (*dto.AppList, error) {
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
data, err := os.ReadFile(listFile)
file, err := os.Open(listFile)
if err != nil {
return nil, err
}
list := &dto.AppList{}
if err = json.Unmarshal(data, list); err != nil {
defer file.Close()
if err = json.NewDecoder(file).Decode(list); err != nil {
return nil, err
}
return list, nil
}
var InitTypes = map[string]struct{}{
"runtime": {},
"php": {},
"node": {},
}
func deleteCustomApp() {
installs, err := appInstallRepo.ListBy(context.Background())
if err != nil {
@@ -1076,10 +1088,6 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
return nil, "", "", err
}
if appicon.IsRemoteURL(app.Icon) {
return nil, app.Icon, "", nil
}
if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName)
-341
View File
@@ -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"
"maps"
"math"
"net/http"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
@@ -29,6 +31,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/docker/docker/api/types/container"
"github.com/joho/godotenv"
"github.com/pkg/errors"
@@ -256,15 +259,6 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil {
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()) {
return buserr.New("ErrInstallDirNotFound")
}
@@ -348,6 +342,9 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
return errors.New("lifecycle script operation not supported")
}
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)
if err != nil {
@@ -358,17 +355,10 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
}
return err
}
ready := make(chan error, 1)
started := make(chan struct{})
operation := string(req.Operate)
operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
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 {
return err
}
@@ -395,12 +385,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
)
go func() {
if taskErr := operationTask.Execute(); taskErr != nil {
select {
case <-started:
default:
ready <- taskErr
return
}
if onFailure != nil {
onFailure(taskErr)
return
@@ -410,7 +394,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
_ = appInstallRepo.Save(context.Background(), &install)
}
}()
return <-ready
return nil
}
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
@@ -490,10 +474,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
}
backupDockerCompose := installed.DockerCompose
updateGPU := installed.App.Key == "ollama" && req.GPU != nil
if req.Advanced || updateGPU {
if req.Advanced {
composeMap := make(map[string]interface{})
if req.Advanced && req.EditCompose {
if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return err
}
@@ -502,35 +485,16 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err
}
}
if req.Advanced && !req.SkipComposeCommonConfig {
if !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err
}
}
if updateGPU {
services, _ := composeMap["services"].(map[string]interface{})
service, ok := services[installed.ServiceName].(map[string]interface{})
if !ok {
return buserr.New("ErrFileParse")
}
gpuParams := map[string]interface{}{}
if err := json.Unmarshal([]byte(installed.Env), &gpuParams); err != nil {
return err
}
for key, value := range req.Params {
gpuParams[key] = value
}
if err := ApplyModelGPU(service, req.GPU, gpuParams, "nvidia"); err != nil {
return err
}
}
composeByte, err := yaml.Marshal(composeMap)
if err != nil {
return err
}
installed.DockerCompose = string(composeByte)
}
if req.Advanced {
if req.ContainerName == "" {
req.Params[constant.ContainerName] = installed.ContainerName
} else {
@@ -779,7 +743,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
}
if canUpgrade {
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"))
if readErr != nil {
return versions, readErr
@@ -790,6 +754,33 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
}
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 {
majorVersion := getMajorVersion(install.Version)
if !strings.HasPrefix(detail.Version, majorVersion) {
@@ -801,46 +792,13 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version,
DetailId: detail.ID,
DockerCompose: newCompose,
})
}
}
sort.Slice(versions, func(i, j int) bool {
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
})
if len(versions) == 0 || (req.LoadCompose != nil && !*req.LoadCompose) {
return versions, nil
}
target := 0
if req.UpdateVersion != "" {
target = -1
for i := range versions {
if versions[i].Version == req.UpdateVersion {
target = i
break
}
}
if target == -1 {
return versions, buserr.New("ErrAppVersionUnavailable")
}
}
if app.Key == vllmAppKeyForUpgrade {
versions[target].DockerCompose = install.DockerCompose
return versions, nil
}
for _, detail := range details {
if detail.ID != versions[target].DetailId {
continue
}
if err := ensureAppDetailCompose(app, &detail); err != nil {
return versions, err
}
content, err := getUpgradeCompose(install, detail)
if err != nil {
return versions, err
}
versions[target].DockerCompose = content
break
}
return versions, nil
}
@@ -1036,16 +994,6 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" {
var project map[string]interface{}
if err := yaml.Unmarshal([]byte(install.DockerCompose), &project); err != nil {
return nil, err
}
services, _ := project["services"].(map[string]interface{})
service, ok := services[install.ServiceName].(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
res.GPU = ReadModelGPU(service, envs, "nvidia")
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil {
return nil, err
@@ -1059,22 +1007,9 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
res.WebUI = install.WebUI
res.Type = install.App.Type
rawCompose := install.DockerCompose
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
}
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
res.RawCompose = rawCompose
res.IsEdit = !equal
}
return &res, nil
}
@@ -1101,16 +1036,8 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
}
switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusWaiting:
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil
case constant.StatusRestarting:
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return nil
}
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force {
return nil
-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"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
@@ -12,11 +17,15 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
type appSyncContext struct {
task *task.Task
httpClient http.Client
baseRemoteUrl string
systemVersion string
appsMap map[string]model.App
@@ -25,6 +34,7 @@ type appSyncContext struct {
oldAppIds []uint
appTags []*model.AppTag
skipMetaSync bool
pendingIcons map[string]string
}
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
@@ -41,10 +51,6 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil
}
if err := refreshOfficialAppIcons(updateRes.AppList, t); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
return err
}
global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true}
@@ -76,11 +82,13 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx := &appSyncContext{
task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.MultiNodeProvider.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
systemVersion: setting.SystemVersion,
settingService: settingService,
list: list,
appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
}
if err = SyncTags(list.Extra); err != nil {
@@ -99,7 +107,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
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
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 {
appDef dto.AppDefine
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
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
if l.AppProperty.Recommend > 0 {
@@ -180,12 +189,24 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
continue
}
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
if _, ok := InitTypes[app.Type]; ok {
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.Params = string(paramByte)
detail.DownloadUrl = downloadURL
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true
detail.LastModified = v.LastModified
@@ -198,25 +219,170 @@ func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
}
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)
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]
if ok {
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
workItems := make([]appWorkItem, 0, total)
for _, l := range c.list.Apps {
app, ok := c.appsMap[l.AppProperty.Key]
if !ok {
continue
}
if (i+1)%max(1, total/4) == 0 || i+1 == total {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
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
}
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 {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
@@ -245,7 +411,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
ctx.task = t
ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppDetails(); err != nil {
if err = ctx.syncAppIconsAndDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
+2 -3
View File
@@ -216,9 +216,8 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err != nil {
return err
}
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
return err
}
u.detail.DockerCompose = string(composeContent)
_ = appDetailRepo.Update(context.Background(), u.detail)
}
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
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/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
@@ -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 {
return err
}
marked, err := appDetailRepo.MarkDownloaded(appDetail)
if err != nil {
return err
}
if !marked {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
appDetail.Update = false
_ = appDetailRepo.Update(context.Background(), appDetail)
return
}
@@ -1274,8 +1268,9 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails
}
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
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))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps {
old.Status = constant.AppTakeDown
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.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport
if appicon.IsRemoteURL(item.Icon) {
app.Icon = item.Icon
} else if item.Icon != "" {
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
if item.Icon != "" {
pendingIcons[key] = item.Icon
}
apps[key] = app
}
return apps
return apps, pendingIcons
}
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
@@ -1586,7 +1579,6 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
HttpPort: installed.HttpPort,
HttpsPort: installed.HttpsPort,
AppName: installed.App.Name,
Icon: appicon.RemoteURL(installed.App.Icon),
AppKey: installed.App.Key,
AppType: installed.App.Type,
Path: installed.GetPath(),
+23
View File
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
backData = records[start:end]
}
// Batch-load persisted checks; never contact Docker Hub while rendering the list.
ids := make([]string, 0, len(backData))
for _, row := range backData {
ids = append(ids, row.ContainerID)
}
if len(ids) > 0 {
var checks []model.ContainerImageCheckUpdate
if err := global.DB.Where("container_id IN ?", ids).Find(&checks).Error; err != nil {
return 0, nil, err
}
byID := make(map[string]model.ContainerImageCheckUpdate, len(checks))
for _, check := range checks {
byID[check.ContainerID] = check
}
for i := range backData {
check, ok := byID[backData[i].ContainerID]
if ok && sameContainerImage(check.ImageID, backData[i].ImageId) && check.ImageName == backData[i].ImageName {
backData[i].LatestVersion = check.LatestVersion
backData[i].TargetImage = check.TargetImage
backData[i].HasUpdate = check.HasUpdate
}
}
}
for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 {
@@ -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)
return upgradeErr
}
if updated, inspectErr := client.ContainerInspect(ctx, item); inspectErr == nil {
if cacheErr := reconcileContainerImageCheckUpdate(oldContainer.ID, updated.ID, updated.Image, updated.Config.Image); cacheErr != nil {
global.LOG.Warnf("refresh container image update state: %v", cacheErr)
}
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
@@ -307,7 +312,6 @@ func switchContainer(
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
+1 -7
View File
@@ -414,7 +414,7 @@ func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
if showCount == 0 {
return buserr.New("ErrMinQuickJump")
}
if showCount > 5 {
if showCount > 4 {
return buserr.New("ErrMaxQuickJump")
}
@@ -709,12 +709,6 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ {
switch quicks[i].Name {
case "ModelServices":
count, err := agentAccountRepo.Count()
if err != nil {
global.LOG.Errorf("load model service count: %v", err)
}
quicks[i].Detail = fmt.Sprintf("%d", count)
case "File":
count, err := launcherRepo.CountQuickDirectories()
if err != nil {
-669
View File
@@ -1,669 +0,0 @@
package service
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/template"
)
func LoadModelGPUDevices() dto.ModelGPUDevices {
result := dto.ModelGPUDevices{Devices: []dto.ModelGPUDevice{}, Warnings: []string{}}
ok, client := accelerator.New()
if !ok {
return result
}
snapshot, err := client.Collect(context.Background())
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
return result
}
result.Warnings = append(result.Warnings, snapshot.Info.Warnings...)
for _, monitored := range snapshot.Devices {
if monitored.ParentID != "" {
continue
}
device := dto.ModelGPUDevice{Available: true}
devicePath := ""
switch {
case monitored.GPU != nil:
if monitored.Vendor == "nvidia" && !re.GetRegex(re.NvidiaGPUUUIDPattern).MatchString(monitored.GPU.UUID) {
result.Warnings = append(result.Warnings, fmt.Sprintf("invalid NVIDIA GPU UUID from nvidia-smi: %s", monitored.GPU.UUID))
continue
}
device.ID = monitored.ID
device.Name = fmt.Sprintf("GPU %d · %s", monitored.Index, monitored.Name)
device.Type = monitored.Vendor
device.UUID = monitored.GPU.UUID
case monitored.XPU != nil:
devicePath = monitored.XPU.Basic.DrmDevice
if !re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(devicePath) {
directory := filepath.Join("/sys/bus/pci/devices", filepath.Base(monitored.BusID), "drm")
entries, err := os.ReadDir(directory)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("resolve Intel GPU %d render device: %v", monitored.Index, err))
continue
}
devicePath = ""
for _, entry := range entries {
if re.GetRegex(re.IntelGPURenderNamePattern).MatchString(entry.Name()) {
devicePath = filepath.Join("/dev/dri", entry.Name())
break
}
}
if devicePath == "" {
result.Warnings = append(result.Warnings, fmt.Sprintf("Intel GPU %d has no render device", monitored.Index))
continue
}
}
device.ID = "intel:" + devicePath
device.Name = fmt.Sprintf("XPU %d · %s", monitored.Index, monitored.Name)
device.Type = "intel"
case monitored.NPU != nil:
device.ID = fmt.Sprintf("ascend:%d", monitored.Index)
device.Name = fmt.Sprintf("NPU %d · Chip %d · %s", monitored.NPUIndex, monitored.ChipIndex, monitored.Name)
device.Type = "ascend"
devicePath = fmt.Sprintf("/dev/davinci%d", monitored.Index)
default:
continue
}
if devicePath != "" {
info, err := os.Stat(devicePath)
device.Available = err == nil && info.Mode()&os.ModeCharDevice != 0
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
}
}
result.Devices = append(result.Devices, device)
}
return result
}
func ApplyModelGPU(service map[string]interface{}, config *dto.ModelGPUConfig, params map[string]interface{}, imageType string) error {
if config == nil {
return nil
}
if err := validateModelGPU(config, imageType); err != nil {
return err
}
resolved, err := resolveModelGPUService(service, params, false)
if err != nil {
return err
}
devicePaths := []string{}
ids := []string{}
logicalIDs := []string{}
if config.Enabled && imageType != "nvidia" {
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
if imageType == "ascend" {
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
visibleIDs := ids
if config.Mode == "all" || resolved["privileged"] == true || resolved["privileged"] == "true" {
visibleIDs = loadModelAscendDeviceIDs()
if len(visibleIDs) == 0 {
return fmt.Errorf("no available Ascend devices")
}
}
if config.Mode == "all" {
ids = visibleIDs
} else {
for _, id := range ids {
index := slices.Index(visibleIDs, id)
if index < 0 {
return fmt.Errorf("Ascend GPU is unavailable: %s", id)
}
logicalIDs = append(logicalIDs, strconv.Itoa(index))
}
}
for _, id := range ids {
devicePaths = append(devicePaths, "/dev/davinci"+id)
}
devicePaths = append(devicePaths, "/dev/davinci_manager", "/dev/devmm_svm", "/dev/hisi_hdc")
} else if config.Mode == "all" {
devicePaths = append(devicePaths, "/dev/dri")
} else {
devicePaths = append(devicePaths, ids...)
}
}
if imageType == "nvidia" {
delete(service, "gpus")
}
if imageType == "nvidia" && resolved["runtime"] == "nvidia" && !config.Enabled {
delete(service, "runtime")
}
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
devices, _ := reservations["devices"].([]interface{})
resolvedDevices, _ := resolved["reservations"].([]interface{})
kept := []interface{}{}
var gpuDevice map[string]interface{}
for index, item := range devices {
device, _ := resolvedDevices[index].(map[string]interface{})
capabilities, _ := device["capabilities"].([]interface{})
driver, _ := device["driver"].(string)
isNvidiaGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if imageType != "nvidia" || !isNvidiaGPU {
kept = append(kept, item)
} else if gpuDevice == nil {
gpuDevice, _ = item.(map[string]interface{})
}
}
if config.Enabled && imageType == "nvidia" {
device := gpuDevice
if device == nil {
device = map[string]interface{}{}
}
device["driver"] = "nvidia"
device["capabilities"] = config.Capabilities
delete(device, "count")
delete(device, "device_ids")
switch config.Mode {
case "all":
device["count"] = "all"
case "count":
device["count"] = config.Count
default:
ids := []string{}
for _, id := range config.DeviceIDs {
ids = append(ids, strings.TrimPrefix(id, "nvidia:"))
}
device["device_ids"] = ids
}
kept = append(kept, device)
}
if len(kept) > 0 {
if deploy == nil {
deploy = map[string]interface{}{}
service["deploy"] = deploy
}
if resources == nil {
resources = map[string]interface{}{}
deploy["resources"] = resources
}
if reservations == nil {
reservations = map[string]interface{}{}
resources["reservations"] = reservations
}
reservations["devices"] = kept
} else {
delete(reservations, "devices")
if len(reservations) == 0 {
delete(resources, "reservations")
}
if len(resources) == 0 {
delete(deploy, "resources")
}
if len(deploy) == 0 {
delete(service, "deploy")
}
}
mappedPaths := []string{}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
resolvedMappings, _ := resolved[key].([]interface{})
kept := []interface{}{}
for index, item := range mappings {
source := modelGPUDeviceSource(resolvedMappings[index])
managed := false
switch imageType {
case "nvidia":
managed = strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=")
case "intel":
managed = source == "/dev/dri" || strings.HasPrefix(source, "/dev/dri/")
case "ascend":
managed = strings.HasPrefix(source, "/dev/davinci") || source == "/dev/devmm_svm" || source == "/dev/hisi_hdc"
}
if managed {
selected := slices.Contains(devicePaths, source)
if config.Enabled && config.Mode == "all" && imageType == "intel" {
selected = true
}
if selected {
kept = append(kept, item)
mappedPaths = append(mappedPaths, source)
}
continue
}
kept = append(kept, item)
}
if len(kept) == 0 {
delete(service, key)
} else {
service[key] = kept
}
}
if config.Enabled && imageType != "nvidia" {
devices, _ := service["devices"].([]interface{})
for _, path := range devicePaths {
if !slices.Contains(mappedPaths, path) {
devices = append(devices, path+":"+path)
}
}
if len(devices) > 0 {
service["devices"] = devices
}
}
gpuEnvironment := map[string]string{}
if config.Enabled && imageType == "ascend" && config.Mode != "all" {
gpuEnvironment["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
var environmentKeys []string
switch imageType {
case "nvidia":
environmentKeys = []string{"NVIDIA_VISIBLE_DEVICES", "NVIDIA_DRIVER_CAPABILITIES"}
case "ascend":
environmentKeys = []string{"ASCEND_RT_VISIBLE_DEVICES"}
if params != nil {
params["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
default:
return nil
}
switch environment := service["environment"].(type) {
case map[string]interface{}:
for _, key := range environmentKeys {
delete(environment, key)
}
for key, value := range gpuEnvironment {
environment[key] = value
}
case []interface{}:
kept := []interface{}{}
for _, item := range environment {
value, ok := item.(string)
if !ok {
return fmt.Errorf("invalid Compose environment")
}
key, _, _ := strings.Cut(value, "=")
if !slices.Contains(environmentKeys, key) {
kept = append(kept, item)
}
}
for key, value := range gpuEnvironment {
kept = append(kept, key+"="+value)
}
service["environment"] = kept
case nil:
if len(gpuEnvironment) > 0 {
environment := map[string]interface{}{}
for key, value := range gpuEnvironment {
environment[key] = value
}
service["environment"] = environment
}
default:
return fmt.Errorf("invalid Compose environment")
}
return nil
}
func ReadModelGPU(service map[string]interface{}, params map[string]interface{}, imageType string) *dto.ModelGPUConfig {
resolved, err := resolveModelGPUService(service, params, true)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
if imageType == "nvidia" {
config, err := readModelGPU(service, resolved)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
return config
}
config := &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
privileged := resolved["privileged"] == true || resolved["privileged"] == "true"
all := false
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
id := ""
if imageType == "intel" {
switch {
case source == "/dev/dri":
all = true
case re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(source):
id = imageType + ":" + source
case strings.HasPrefix(source, "/dev/dri/"):
config.Error = "unsupported Intel GPU mapping requires an explicit selection: " + source
return config
}
}
if imageType == "ascend" && re.GetRegex(re.AscendGPUDevicePathPattern).MatchString(source) {
id = imageType + ":" + strings.TrimPrefix(source, "/dev/davinci")
}
if id != "" && !slices.Contains(config.DeviceIDs, id) {
config.DeviceIDs = append(config.DeviceIDs, id)
}
}
}
config.Enabled = all || len(config.DeviceIDs) > 0
config.Mode = "devices"
if all {
config.Mode = "all"
config.DeviceIDs = []string{}
}
if imageType == "ascend" {
const key = "ASCEND_RT_VISIBLE_DEVICES"
visible := ""
switch environment := service["environment"].(type) {
case map[string]interface{}:
if value, exists := environment[key]; exists {
if value == nil {
visible = "${" + key + "}"
} else {
visible = fmt.Sprint(value)
}
}
case []interface{}:
for _, item := range environment {
value, _ := item.(string)
if value == key {
visible = "${" + key + "}"
} else if strings.HasPrefix(value, key+"=") {
visible = strings.TrimPrefix(value, key+"=")
}
}
}
visible, err := resolveModelGPUValue(visible, params)
if err != nil {
config.Error = err.Error()
return config
}
visible = strings.TrimSpace(visible)
if visible == "all" || (visible == "" && config.Enabled) {
config.Enabled = true
config.Mode = "all"
config.DeviceIDs = []string{}
} else if visible != "" {
physicalIDs := []string{}
for _, id := range config.DeviceIDs {
physicalIDs = append(physicalIDs, strings.TrimPrefix(id, "ascend:"))
}
slices.SortFunc(physicalIDs, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
if privileged {
physicalIDs = loadModelAscendDeviceIDs()
}
selected := []string{}
for _, id := range strings.Split(visible, ",") {
id = strings.TrimSpace(id)
if !re.GetRegex(re.AscendGPUDeviceIDPattern).MatchString(id) {
config.Error = "invalid Ascend GPU device: " + id
return config
}
index, err := strconv.Atoi(id)
if err != nil || index < 0 || index >= len(physicalIDs) {
config.Error = "Ascend logical GPU is not available in the container: " + id
return config
}
selected = append(selected, "ascend:"+physicalIDs[index])
}
config.Enabled = true
config.Mode = "devices"
config.DeviceIDs = selected
}
}
return config
}
func readModelGPU(service, resolved map[string]interface{}) (*dto.ModelGPUConfig, error) {
config := &dto.ModelGPUConfig{Mode: "all", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
devices, _ := resolved["reservations"].([]interface{})
if service["gpus"] != nil {
return nil, fmt.Errorf("custom GPU declarations require an explicit selection")
}
for _, item := range devices {
device, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid GPU reservation")
}
driver, _ := device["driver"].(string)
capabilities, _ := device["capabilities"].([]interface{})
isGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if !isGPU {
continue
}
if config.Enabled {
return nil, fmt.Errorf("multiple GPU declarations require an explicit selection")
}
config.Enabled = true
ids, _ := device["device_ids"].([]interface{})
if len(ids) > 0 {
if device["count"] != nil {
return nil, fmt.Errorf("GPU count and device_ids cannot be combined")
}
config.Mode = "devices"
for _, id := range ids {
config.DeviceIDs = append(config.DeviceIDs, "nvidia:"+fmt.Sprint(id))
}
} else if count := device["count"]; count != nil && fmt.Sprint(count) != "all" && fmt.Sprint(count) != "-1" {
config.Mode = "count"
countValue, err := strconv.Atoi(fmt.Sprint(count))
config.Count = countValue
if err != nil {
return nil, fmt.Errorf("invalid GPU count: %v", count)
}
}
for _, capability := range capabilities {
value, ok := capability.(string)
if !ok {
return nil, fmt.Errorf("unsupported GPU capabilities: %v", capabilities)
}
if !slices.Contains(config.Capabilities, value) {
config.Capabilities = append(config.Capabilities, value)
}
}
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
if strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=") {
return nil, fmt.Errorf("direct NVIDIA device mappings require an explicit selection")
}
}
}
if !config.Enabled && resolved["runtime"] == "nvidia" {
return nil, fmt.Errorf("custom NVIDIA runtime configuration requires an explicit selection")
}
if err := validateModelGPU(config, "nvidia"); err != nil {
return nil, err
}
return config, nil
}
func validateModelGPU(config *dto.ModelGPUConfig, imageType string) error {
if config.Error != "" {
return fmt.Errorf("GPU configuration must be selected explicitly: %s", config.Error)
}
if !config.Enabled {
return nil
}
patterns := map[string]string{
"nvidia": re.NvidiaGPUDeviceIDPattern,
"intel": re.IntelGPUDevicePathPattern,
"ascend": re.AscendGPUDeviceIDPattern,
}
pattern, supported := patterns[imageType]
if !supported {
return fmt.Errorf("unsupported GPU type: %s", imageType)
}
switch config.Mode {
case "all":
if config.Count != 0 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("all GPUs cannot be combined with count or device IDs")
}
case "count":
if imageType != "nvidia" || config.Count < 1 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("GPU count requires a positive number and runtime device allocation")
}
case "devices", "":
if config.Count != 0 || len(config.DeviceIDs) == 0 {
return fmt.Errorf("select at least one GPU device")
}
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !re.GetRegex(pattern).MatchString(id) {
return fmt.Errorf("invalid %s GPU device: %s", imageType, deviceID)
}
}
default:
return fmt.Errorf("unsupported GPU selection mode: %s", config.Mode)
}
if imageType != "nvidia" {
return nil
}
if !slices.Contains(config.Capabilities, "gpu") {
return fmt.Errorf("NVIDIA capabilities must include gpu")
}
for _, capability := range config.Capabilities {
if capability != "gpu" && capability != "utility" && capability != "compute" {
return fmt.Errorf("unsupported NVIDIA capability: %s", capability)
}
}
return nil
}
func resolveModelGPUService(service map[string]interface{}, params map[string]interface{}, resolveSelection bool) (map[string]interface{}, error) {
fields := map[string]interface{}{}
fields["runtime"] = service["runtime"]
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
fields["reservations"] = reservations["devices"]
if devices, ok := reservations["devices"].([]interface{}); ok && !resolveSelection {
declarations := make([]interface{}, 0, len(devices))
for _, item := range devices {
device, _ := item.(map[string]interface{})
declarations = append(declarations, map[string]interface{}{"driver": device["driver"], "capabilities": device["capabilities"]})
}
fields["reservations"] = declarations
}
resolved, err := interpolation.Interpolate(fields, interpolation.Options{
Substitute: func(value string, _ template.Mapping) (string, error) { return resolveModelGPUValue(value, params) },
})
if err != nil {
return nil, err
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
sources := make([]interface{}, 0, len(mappings))
for _, item := range mappings {
source, err := template.SubstituteWithOptions(modelGPUDeviceSource(item), func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging)
if err != nil {
return nil, err
}
if _, short := item.(string); short {
source = modelGPUDeviceSource(source)
}
sources = append(sources, map[string]interface{}{"source": source})
}
resolved[key] = sources
}
resolved["privileged"] = service["privileged"]
if value, ok := service["privileged"].(string); ok {
if value, err := resolveModelGPUValue(value, params); err == nil {
resolved["privileged"] = value
}
}
return resolved, nil
}
func modelGPUDeviceSource(item interface{}) string {
switch mapping := item.(type) {
case string:
if strings.HasPrefix(mapping, "nvidia.com/gpu=") {
return mapping
}
depth := 0
for index, ch := range mapping {
switch ch {
case '{':
depth++
case '}':
depth--
case ':':
if depth == 0 {
return mapping[:index]
}
}
}
return mapping
case map[string]interface{}:
source, _ := mapping["source"].(string)
return source
}
return ""
}
func resolveModelGPUValue(value string, params map[string]interface{}) (string, error) {
return template.SubstituteWithOptions(value, func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging, template.WithReplacementFunction(func(value string, mapping template.Mapping, config *template.Config) (string, error) {
resolved, applied, err := template.DefaultReplacementAppliedFunc(value, mapping, config)
if err != nil {
return "", err
}
if !applied {
return "", fmt.Errorf("unresolved GPU variable: %s", value)
}
return resolved, nil
}))
}
func loadModelAscendDeviceIDs() []string {
ids := []string{}
entries, err := os.ReadDir("/dev")
if err != nil {
return ids
}
for _, entry := range entries {
if !re.GetRegex(re.AscendGPUDeviceNamePattern).MatchString(entry.Name()) {
continue
}
info, err := os.Stat(filepath.Join("/dev", entry.Name()))
if err == nil && info.Mode()&os.ModeCharDevice != 0 {
ids = append(ids, strings.TrimPrefix(entry.Name(), "davinci"))
}
}
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
return ids
}
+1 -11
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -20,7 +19,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"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/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/psutil"
"github.com/robfig/cron/v3"
@@ -207,14 +205,7 @@ func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
continue
}
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 == "",
SharedMemory: strings.HasPrefix(device.DeviceID, "nvidia:") && gpu.UsesSystemMemory(productName),
})
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{DeviceID: device.DeviceID, ProductName: device.ProductName, Type: device.DeviceType, Legacy: device.DeviceID == ""})
data.Options = append(data.Options, device.ProductName)
}
return data
@@ -809,7 +800,6 @@ func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
})
for _, item := range snapshot.Devices {
chartHide := dto.GPUChartHide{
SharedMemory: item.GPU != nil && item.GPU.SharedMemory,
DeviceID: item.ID,
ProductName: item.Label,
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.Image = envs["IMAGE"]
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)
composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml"))
if err == nil {
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)
if err == nil {
@@ -152,9 +133,6 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
var environments []interface{}
environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir))
for _, e := range create.Environments {
if e.Key == "MODEL_PATH" {
continue
}
environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value))
}
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))
}
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
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 {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
return t.executeRegistered()
+5 -1
View File
@@ -77,7 +77,11 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err)
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())
}
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)
}
}
}
-2
View File
@@ -2,8 +2,6 @@ module github.com/1Panel-dev/1Panel/agent
go 1.27.3
godebug multipathtcp=0
replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
require (
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'Requested resource not found: {{ .err }}'
# quick jump
ErrMinQuickJump: 'Set at least one quick jump entry'
ErrMaxQuickJump: 'At most 5 quick jump entries'
ErrMaxQuickJump: 'At most 4 quick jump entries'
# common
ErrShutDown: 'Command stopped manually'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'The certificate file does not exist'
ErrSSLKeyFormat: 'Private key file verification failed'
ErrSSLCertificateFormat: 'Invalid certificate format; use PEM'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid or EabHmacKey cannot be blank'
ErrOpenrestyInstall: "Please install OpenResty from the App Store first"
ErrOpenrestyNotFound: 'HTTP mode requires OpenResty'
ApplySSLStart: 'Starting certificate request. Domain: [{{ .domain }}], method: [{{ .type }}]'
dnsAccount: 'DNS Auto'
@@ -803,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."
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."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'No se pudo encontrar el recurso solicitado {{ .err }}'
# salto rápido
ErrMinQuickJump: 'Agrega al menos una entrada rápida'
ErrMaxQuickJump: 'Máximo 5 entradas rápidas'
ErrMaxQuickJump: 'Máximo 4 entradas rápidas'
# común
ErrShutDown: 'Comando detenido manualmente'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'El archivo de certificado no existe'
ErrSSLKeyFormat: 'Error en la verificación del archivo de clave privada'
ErrSSLCertificateFormat: 'Formato de certificado incorrecto, use formato pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid o EabHmacKey no pueden estar en blanco'
ErrOpenrestyInstall: "Instale primero OpenResty desde la tienda de aplicaciones"
ErrOpenrestyNotFound: 'El modo HTTP requiere OpenResty'
ApplySSLStart: 'Iniciando solicitud de certificado, dominio [{{ .domain }}] método de solicitud [{{ .type }}]'
dnsAccount: 'DNS Automático'
@@ -803,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."
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."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'منبع درخواستی یافت نشد: {{ .err }}'
# پرش سریع
ErrMinQuickJump: 'حداقل یک ورودی پرش سریع تنظیم کنید'
ErrMaxQuickJump: 'حداکثر ۵ ورودی پرش سریع'
ErrMaxQuickJump: 'حداکثر ۴ ورودی پرش سریع'
# عمومی
ErrShutDown: 'دستور به صورت دستی متوقف شد'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'فایل گواهی وجود ندارد'
ErrSSLKeyFormat: 'تأیید فایل کلید خصوصی ناموفق بود'
ErrSSLCertificateFormat: 'فرمت گواهی نامعتبر است؛ از PEM استفاده کنید'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid یا EabHmacKey نمی‌توانند خالی باشند'
ErrOpenrestyInstall: "لطفاً ابتدا OpenResty را از فروشگاه برنامه‌ها نصب کنید"
ErrOpenrestyNotFound: 'حالت HTTP نیاز به OpenResty دارد'
ApplySSLStart: 'شروع درخواست گواهی. دامنه: [{{ .domain }}]، روش: [{{ .type }}]'
dnsAccount: 'خودکار DNS'
@@ -804,3 +803,15 @@ ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کام
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
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: "برای بررسی به‌روزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: '要求されたリソース {{ .err }} が見つかりま
# クイックジャンプ
ErrMinQuickJump: 'クイックアクセスを1件以上追加'
ErrMaxQuickJump: '最大5件のクイックアクセス'
ErrMaxQuickJump: '最大4件のクイックアクセス'
# 共通
ErrShutDown: 'コマンドは手動で停止されました'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: '証明書ファイルが存在しません'
ErrSSLKeyFormat: '秘密鍵ファイルの検証に失敗しました'
ErrSSLCertificateFormat: '証明書ファイルの形式が正しくありません。pem 形式を使用してください'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid または EabHmacKey は空白にできません'
ErrOpenrestyInstall: "先にアプリストアから OpenResty をインストールしてください"
ErrOpenrestyNotFound: 'HTTP モードでは OpenResty が必要です'
ApplySSLStart: '証明書の申請を開始します。ドメイン名 [{{ .domain }}] 申請方法 [{{ .type }}]'
dnsAccount: 'DNS 自動'
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
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: "現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。"
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: '요청한 리소스 {{ .err }}을 찾을 수 없습니다.'
# 빠른 이동
ErrMinQuickJump: '퀵 액세스를 하나 이상 추가하세요'
ErrMaxQuickJump: '퀵 액세스는 최대 5개'
ErrMaxQuickJump: '퀵 액세스는 최대 4개'
# 공통
ErrShutDown: '명령이 수동으로 중단되었습니다'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: '인증서 파일이 존재하지 않습니다'
ErrSSLKeyFormat: '개인 키 파일 검증에 실패했습니다'
ErrSSLCertificateFormat: '인증서 파일 형식이 올바르지 않습니다. pem 형식을 사용하세요.'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid 또는 EabHmacKey는 비워둘 수 없습니다'
ErrOpenrestyInstall: "먼저 앱 스토어에서 OpenResty를 설치하세요"
ErrOpenrestyNotFound: 'HTTP 모드에서는 OpenResty가 필요합니다'
ApplySSLStart: '인증서 신청 시작, 도메인 이름 [{{ .domain }}] 신청 방법 [{{ .type }}]'
dnsAccount: 'DNS 자동'
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
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: "현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요."
+13 -2
View File
@@ -32,7 +32,7 @@ ErrApiConfigKeyTimeInvalid: 'ເວລາ (Timestamp) ຂອງ API ບໍ່ຖ
ErrShutDown: 'ຄຳສັ່ງຖືກຢຸດດ້ວຍຕົນເອງ'
ErrMinQuickJump: 'ກະລຸນາຕັ້ງຄ່າລາຍການທາງລັດຢ່າງໜ້ອຍໜຶ່ງລາຍການ'
ErrMaxQuickJump: 'ສາມາດຕັ້ງຄ່າລາຍການທາງລັດໄດ້ສູງສຸດ 5 ລາຍການ'
ErrMaxQuickJump: 'ສາມາດຕັ້ງຄ່າລາຍການທາງລັດໄດ້ສູງສຸດ 4 ລາຍການ'
#common
ErrUsernameIsExist: 'ຊື່ຜູ້ໃຊ້ມີຢູ່ໃນລະບົບແລ້ວ'
@@ -242,7 +242,6 @@ ErrSSLCertificateNotFound: 'ບໍ່ມີໄຟລ໌ໃບຮັບຮອງ
ErrSSLKeyFormat: 'ການກວດສອບໄຟລ໌ກຸນແຈສ່ວນຕົວລົ້ມເຫຼວ'
ErrSSLCertificateFormat: 'ຮູບແບບໃບຮັບຮອງບໍ່ຖືກຕ້ອງ; ກະລຸນາໃຊ້ PEM'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid ຫຼື EabHmacKey ບໍ່ສາມາດປະຫວ່າງໄດ້'
ErrOpenrestyInstall: "ກະລຸນາຕິດຕັ້ງ OpenResty ຈາກຮ້ານແອັບກ່ອນ"
ErrOpenrestyNotFound: 'ໂໝດ HTTP ຕ້ອງການ OpenResty'
ApplySSLStart: 'ກຳລັງເລີ່ມການຮ້ອງຂໍໃບຮັບຮອງ. ໂດເມນ: [{{ .domain }}], ວິທີການ: [{{ .type }}]'
dnsAccount: 'DNS ອັດຕະໂນມັດ'
@@ -794,3 +793,15 @@ ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
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: "ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'Sumber yang diminta {{ .err }} tidak dapat ditemui'
# lompat pantas
ErrMinQuickJump: 'Tambah sekurang-kurangnya satu pintasan pantas'
ErrMaxQuickJump: 'Maksimum 5 pintasan pantas'
ErrMaxQuickJump: 'Maksimum 4 pintasan pantas'
# umum
ErrShutDown: 'Arahan diberhentikan manual'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'Fail sijil tidak wujud'
ErrSSLKeyFormat: 'Pengesahan fail kunci peribadi gagal'
ErrSSLCertificateFormat: 'Format fail sijil tidak betul, sila gunakan format pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid atau EabHmacKey tidak boleh kosong'
ErrOpenrestyInstall: "Sila pasang OpenResty daripada gedung aplikasi terlebih dahulu"
ErrOpenrestyNotFound: 'Mod HTTP memerlukan OpenResty dipasang terlebih dahulu'
ApplySSLStart: 'Mula memohon sijil, nama domain [{{ .domain }}] kaedah permohonan [{{ .type }}]'
dnsAccount: 'DNS automatik'
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuh
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
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."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'O recurso solicitado {{ .err }} não pôde ser encontrado'
# atalho rápido
ErrMinQuickJump: 'Adicione pelo menos um atalho rápido'
ErrMaxQuickJump: 'Máximo 5 atalhos rápidos'
ErrMaxQuickJump: 'Máximo 4 atalhos rápidos'
# comum
ErrShutDown: 'Comando parado manualmente'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'O arquivo de certificado não existe'
ErrSSLKeyFormat: 'Falha na verificação do arquivo de chave privada'
ErrSSLCertificateFormat: 'O formato do arquivo de certificado está incorreto, use o formato pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid ou EabHmacKey não podem estar em branco'
ErrOpenrestyInstall: "Instale primeiro o OpenResty pela loja de aplicativos"
ErrOpenrestyNotFound: 'O modo HTTP requer o OpenResty instalado'
ApplySSLStart: 'Comece a solicitar um certificado, nome de domínio [{{ .domain }}] método de solicitação [{{ .type }}]'
dnsAccount: 'DNS Automático'
@@ -803,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."
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."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'Запрошенный ресурс {{ .err }} не най
# быстрый переход
ErrMinQuickJump: 'Добавьте хотя бы одну быструю ссылку'
ErrMaxQuickJump: 'Не более 5 быстрых ссылок'
ErrMaxQuickJump: 'Не более 4 быстрых ссылок'
# общее
ErrShutDown: 'Команда остановлена вручную'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'Файл сертификата не существ
ErrSSLKeyFormat: 'Проверка файла закрытого ключа не удалась'
ErrSSLCertificateFormat: 'Неверный формат файла сертификата, используйте формат pem'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid или EabHmacKey не могут быть пустыми'
ErrOpenrestyInstall: "Сначала установите OpenResty из магазина приложений"
ErrOpenrestyNotFound: 'Для режима HTTP требуется OpenResty'
ApplySSLStart: 'Начать подачу заявки на сертификат, доменное имя [{{ .domain }}], метод подачи заявки [{{ .type }}]'
dnsAccount: 'DNS Авто'
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "Эти монтирования не поддержив
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
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: "Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: 'İstenen kaynak {{ .err }} bulunamadı'
# hızlı geçiş
ErrMinQuickJump: 'En az bir hızlı erişim ekle'
ErrMaxQuickJump: '5 hızlı erişim hakkı var'
ErrMaxQuickJump: '4 hızlı erişim hakkı var'
# genel
ErrShutDown: 'Komut manuel durduruldu'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: 'Sertifika dosyası mevcut değil'
ErrSSLKeyFormat: 'Özel anahtar dosyası doğrulama başarısız'
ErrSSLCertificateFormat: 'Sertifika dosyası formatı yanlış, lütfen pem formatını kullanın'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid veya EabHmacKey boş olamaz'
ErrOpenrestyInstall: "Lütfen önce uygulama mağazasından OpenResty yükleyin"
ErrOpenrestyNotFound: 'HTTP modu için OpenResty gereklidir'
ApplySSLStart: 'Sertifika başvurusu başlatılıyor, alan adı [{{ .domain }}] başvuru yöntemi [{{ .type }}]'
dnsAccount: 'DNS Otomatik'
@@ -803,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."
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."
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: '無法找到要求的資源{{ .err }}'
# 快速跳轉
ErrMinQuickJump: '請至少設定一個快速跳轉入口!'
ErrMaxQuickJump: '最多可設定五個快速跳轉入口!'
ErrMaxQuickJump: '最多可設定四個快速跳轉入口!'
# 通用
ErrShutDown: '指令被手動結束!'
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: '憑證檔案不存在'
ErrSSLKeyFormat: '私鑰檔案驗證失敗'
ErrSSLCertificateFormat: '憑證檔案格式錯誤,請使用 pem 格式'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid 或 EabHmacKey 不可為空'
ErrOpenrestyInstall: "請先在應用程式商店安裝 OpenResty"
ErrOpenrestyNotFound: 'HTTP 模式需要先安裝 OpenResty'
ApplySSLStart: '開始申請憑證,網域名稱[{{ .domain }}] 申請方式[{{ .type }}]'
dnsAccount: 'DNS 自動'
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
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: "目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器"
+13 -2
View File
@@ -46,7 +46,7 @@ ErrHttpReqNotFound: "无法找到请求的资源 {{ .err }}"
# 快速跳转
ErrMinQuickJump: "至少设置 1 个快速跳转入口"
ErrMaxQuickJump: "最多设置 5 个快速跳转入口"
ErrMaxQuickJump: "最多设置 4 个快速跳转入口"
# 通用
ErrShutDown: "命令已手动终止"
@@ -252,7 +252,6 @@ ErrSSLCertificateNotFound: '证书文件不存在'
ErrSSLKeyFormat: '私钥文件校验失败'
ErrSSLCertificateFormat: '证书文件格式错误,请使用 pem 格式'
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid 或 EabHmacKey 不能为空'
ErrOpenrestyInstall: "请先在应用商店安装 OpenResty"
ErrOpenrestyNotFound: 'Http 模式需要首先安装 Openresty'
ApplySSLStart: '开始申请证书,域名 [{{ .domain }}],方式 [{{ .type }}]'
dnsAccount: "DNS 自动"
@@ -803,3 +802,15 @@ ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
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 -1
View File
@@ -119,7 +119,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync,
migrations.AddModelQuickJump,
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
},
}
-13
View File
@@ -1939,16 +1939,3 @@ var AddOllamaModelAccountSync = &gormigrate.Migration{
return nil
},
}
var AddModelQuickJump = &gormigrate.Migration{
ID: "20261030-add-model-quick-jump",
Migrate: func(tx *gorm.DB) error {
return tx.Where("name = ?", "ModelServices").FirstOrCreate(&model.QuickJump{
Name: "ModelServices",
Title: "aiTools.model.model",
Recommend: 0,
IsShow: true,
Router: "/ai/model",
}).Error
},
}
-1
View File
@@ -26,7 +26,6 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain)
aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo)
aiToolsRouter.GET("/model/gpu/devices", baseApi.LoadModelGPUDevices)
aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor)
aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions)
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("/update", baseApi.ContainerUpdate)
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("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer)
+1 -34
View File
@@ -4,12 +4,10 @@ import (
"context"
"encoding/xml"
"fmt"
"slices"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/shirou/gopsutil/v4/mem"
)
const nvidiaSMICommand = "nvidia-smi"
@@ -22,31 +20,7 @@ func (n nvidiaSMI) LoadInfo(ctx context.Context) (*Info, error) {
if err != nil {
return nil, fmt.Errorf("calling %s failed: %w", nvidiaSMICommand, err)
}
info, err := 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")
return parseNvidiaSMI([]byte(itemData))
}
func parseNvidiaSMI(buf []byte) (*Info, error) {
@@ -64,7 +38,6 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
for i := range s.Gpu {
gpuItem := Device{
Type: "nvidia",
SharedMemory: UsesSystemMemory(s.Gpu[i].ProductName),
MemoryActivity: s.Gpu[i].Utilization.MemoryUtil,
EncoderUtil: s.Gpu[i].Utilization.EncoderUtil,
DecoderUtil: s.Gpu[i].Utilization.DecoderUtil,
@@ -99,12 +72,6 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
ComputeMode: s.Gpu[i].ComputeMode,
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.ECCErrors = []ECCError{
{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 {
SharedMemory bool `json:"sharedMemory"`
ECCPending string `json:"eccPending"`
ECCErrors []ECCError `json:"eccErrors"`
MemoryActivity string `json:"memoryActivity"`
-1
View File
@@ -72,7 +72,6 @@ type Device struct {
}
type Basic struct {
DrmDevice string `json:"-"`
UUID string `json:"uuid"`
DeviceID int `json:"deviceID"`
-4
View File
@@ -93,7 +93,6 @@ func (c Client) loadDeviceInfo(ctx context.Context, device discoveryDevice, wg *
xpu := Device{
ProcessStatus: "unavailable",
Basic: Basic{
DrmDevice: device.DrmDevice,
UUID: device.UUID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
@@ -138,9 +137,6 @@ func (c Client) loadDeviceInfo(ctx context.Context, device discoveryDevice, wg *
return
}
if info.DrmDevice != "" {
xpu.Basic.DrmDevice = info.DrmDevice
}
xpu.Basic.DriverVersion = info.DriverVersion
bytes, err := strconv.ParseInt(info.MemoryPhysicalSizeByte, 10, 64)
-29
View File
@@ -6,42 +6,13 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/1Panel-dev/1Panel/agent/global"
)
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 {
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*$`
AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)`
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)
@@ -129,13 +122,6 @@ func Init() {
AcceleratorMetricValuePattern,
AscendVersionPattern,
AscendMemoryPattern,
NvidiaGPUUUIDPattern,
NvidiaGPUDeviceIDPattern,
IntelGPURenderNamePattern,
IntelGPUDevicePathPattern,
AscendGPUDeviceNamePattern,
AscendGPUDevicePathPattern,
AscendGPUDeviceIDPattern,
}
for _, pattern := range patterns {
+68
View File
@@ -7,13 +7,17 @@ import (
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
)
func HandleGet(url string) (*http.Response, error) {
@@ -162,3 +166,67 @@ func loadRequestTransport() *http.Transport {
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 {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return false
}
@@ -41,24 +41,6 @@ func apiKeyError(c *gin.Context, err error) {
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) {
var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) {
@@ -73,25 +55,6 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
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) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) {
@@ -106,22 +69,6 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
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) {
var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) {
@@ -134,21 +81,6 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
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) {
var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) {
@@ -161,22 +93,6 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
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) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
@@ -190,63 +106,5 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
}
func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, dto.APIKeyMutationResult{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)
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
+8 -11
View File
@@ -417,13 +417,11 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
}
// @Tags Auth
// @Summary Generate or reset the legacy 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.
// @Summary generate api key
// @Accept json
// @Produce json
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Success 200 {string} key
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -442,13 +440,12 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
}
// @Tags Auth
// @Summary Update the legacy API key configuration
// @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.
// @Summary Update api config
// @Accept json
// @Produce json
// @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"
// @Security PanelSession || CSRFToken
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @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]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+1 -16
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex"
"errors"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -43,7 +42,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0]
}
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()
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) {
settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{}
+3 -23
View File
@@ -66,26 +66,6 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
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 {
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) {
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid")
}
master, err := encrypt.APIKeyEncryptionKey()
@@ -118,7 +98,7 @@ func APIKeySecret(key model.APIKey) (string, 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")
}
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) {
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 != "" {
q = q.Where("id = ?", keyID)
} else {
+27 -35
View File
@@ -6,42 +6,41 @@ import (
)
type APIKeyItem struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
Name string `json:"name" example:"Deployment automation"`
Description string `json:"description" example:"Used by the deployment service"`
KeyHint string `json:"keyHint" example:"abcd••••1234"`
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
Revision uint64 `json:"revision" minimum:"1" example:"1"`
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
KeyHint string `json:"keyHint"`
Status string `json:"status"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt *time.Time `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
Revision uint64 `json:"revision"`
CreatedAt *time.Time `json:"createdAt"`
}
type APIKeySearch struct {
Page int `json:"page" minimum:"1" example:"1"`
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked"`
}
type APIKeyPage struct {
Items []APIKeyItem `json:"items"`
Total int `json:"total"`
}
type APIKeyFields struct {
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
Name string `json:"name"`
Description string `json:"description"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt json.RawMessage `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
}
type APIKeyCreate struct {
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
RequestID string `json:"requestID"`
APIKeyFields
}
type APIKeyCreated struct {
@@ -50,15 +49,8 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
}
type APIKeyMutation struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Revision uint64 `json:"revision" minimum:"1" example:"1"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
ID string `json:"id"`
Revision uint64 `json:"revision"`
}
type APIKeyUpdate struct {
APIKeyMutation
@@ -66,5 +58,5 @@ type APIKeyUpdate struct {
}
type APIKeyStatus struct {
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"`
NoAuthSetting string `json:"noAuthSetting"`
ProxyType string `json:"proxyType"`
ProxyEnabled bool `json:"proxyEnabled"`
ScriptSync string `json:"scriptSync"`
-1
View File
@@ -24,7 +24,6 @@ type APIKey struct {
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
}
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) {
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
}
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
if excludeRevoked {
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)
}
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 {
status := key.Status
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) {
owner, err := s.managementOwner(c)
owner, err := s.owner(c)
if err != nil {
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) {
owner, err := s.managementOwner(c)
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.RequestID == "" {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams")
}
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)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash {
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 {
owner, err := s.managementOwner(c)
owner, err := s.owner(c)
if err != nil {
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 {
owner, err := s.managementOwner(c)
owner, err := s.owner(c)
if err != nil {
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 {
owner, err := s.managementOwner(c)
owner, err := s.owner(c)
if err != nil {
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 {
if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams")
}
apiKeyMutationMu.Lock()
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 {
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) {
return buserr.New("ErrAPIKeyNotFound")
}
return err
}
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
if key.Revision != req.Revision || key.Status == "Revoked" {
return buserr.New("ErrAPIKeyConflict")
}
before := key
+2
View File
@@ -179,6 +179,8 @@ func (u *SettingService) GetSettingBaseInfo() (*dto.SettingBaseInfo, error) {
_ = settingRepo.UpdateOrCreate("MenuAccordion", info.MenuAccordion)
}
info.ProxyEnabled = info.ProxyType == "http" || info.ProxyType == "https" || info.ProxyType == "socks5"
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": {
"type": "file"
}
},
"302": {
"description": "redirect to the official icon URL"
}
},
"security": [
@@ -5342,17 +5339,6 @@
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/request.AppUpdateVersion"
}
}
],
"responses": {
"200": {
"description": "OK",
@@ -47491,23 +47477,6 @@
],
"type": "object"
},
"request.AppUpdateVersion": {
"properties": {
"appInstallID": {
"type": "integer"
},
"loadCompose": {
"type": "boolean"
},
"updateVersion": {
"type": "string"
}
},
"required": [
"appInstallID"
],
"type": "object"
},
"response.AppConfig": {
"properties": {
"advanced": {
@@ -47534,9 +47503,6 @@
"hostMode": {
"type": "boolean"
},
"isEdit": {
"type": "boolean"
},
"memoryLimit": {
"type": "number"
},
@@ -47797,9 +47763,6 @@
"gpuSupport": {
"type": "boolean"
},
"icon": {
"type": "string"
},
"id": {
"type": "integer"
},
+2 -5
View File
@@ -18,10 +18,7 @@ import (
// @schemes http https
// @securityDefinitions.apikey ApiKeyAuth
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
// @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 Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
// @description ```
// @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -30,7 +27,7 @@ import (
// @description ```
// @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey
// @in header
// @in Header
// @name 1Panel-Token
// @securityDefinitions.apikey Timestamp
// @type apiKey
-4
View File
@@ -71,7 +71,6 @@ var WebUrlMap = map[string]struct{}{
"/ai/skills-hub": {},
"/ai/skills-hub/targets": {},
"/ai/benchmark": {},
"/ai/model": {},
"/ai/model/account": {},
"/ai/model/local": {},
"/ai/model/local/templates": {},
@@ -160,9 +159,6 @@ var WebUrlMap = map[string]struct{}{
"/websites": {},
"/websites/templates": {},
"/websites/ssl": {},
"/websites/ssl/ca": {},
"/websites/ssl/acme-account": {},
"/websites/ssl/dns-account": {},
"/websites/runtimes/php": {},
"/websites/runtimes/node": {},
"/websites/runtimes/java": {},
-2
View File
@@ -2,8 +2,6 @@ module github.com/1Panel-dev/1Panel/core
go 1.27.3
godebug multipathtcp=0
require (
github.com/beevik/etree v1.7.0
github.com/coreos/go-oidc/v3 v3.20.0
-1
View File
@@ -72,6 +72,5 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrder,
migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
}
}
@@ -15,8 +15,3 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key",
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
}
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":
c.Next()
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/status", baseApi.SetAPIKeyStatus)
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.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"},
"disable": {"停用 API Key", "Disable 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"},
}
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.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 != "" {
record.IP = event.ClientIP
-21
View File
@@ -1,21 +1,6 @@
import { ReqPage } from '.';
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 {
id: number;
modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom';
@@ -49,7 +34,6 @@ export namespace AI {
xpu: XpuInfo['xpu'];
}
export interface GPU {
sharedMemory?: boolean;
eccPending?: string;
eccErrors?: { scope: string; correctable: string; uncorrectable: string }[];
memoryActivity?: string;
@@ -140,7 +124,6 @@ export namespace AI {
chartHide: Array<ChartHide>;
}
export interface ChartHide {
sharedMemory?: boolean;
deviceID?: string;
legacy?: boolean;
productName: string;
@@ -409,7 +392,6 @@ export namespace AI {
}
export interface TensorRTLLM {
gpu?: ModelGPUConfig;
id?: number;
name: string;
containerName: string;
@@ -677,7 +659,6 @@ export namespace AI {
}
export interface AgentAccountModelCreateReq {
taskID?: string;
accountId: number;
model: AgentAccountModel;
}
@@ -688,7 +669,6 @@ export namespace AI {
}
export interface AgentAccountModelDeleteReq {
deleteFiles?: boolean;
accountId: number;
recordId: number;
}
@@ -739,7 +719,6 @@ export namespace AI {
baseURL: string;
apiType: string;
authMode: string;
models?: AgentAccountModel[];
verifyModel: string;
validateAvailability: boolean;
remark: string;
-3
View File
@@ -30,7 +30,4 @@ export namespace APIKey {
id: string;
revision: number;
}
export interface Revealed extends Reference {
apiKey: string;
}
}
-6
View File
@@ -53,7 +53,6 @@ export namespace App {
export interface AppItem {
name: string;
icon?: string;
key: string;
id: number;
description: string;
@@ -138,7 +137,6 @@ export namespace App {
}
export interface AppInstall {
gpu?: AI.ModelGPUConfig;
syncModelAccount?: boolean;
modelAccountBaseURLType?: string;
modelAccountBaseURL?: string;
@@ -293,7 +291,6 @@ export namespace App {
feishuConfigured?: boolean;
version: string;
detailId: number;
dockerCompose?: string;
}
export interface InstallParams {
@@ -313,7 +310,6 @@ export namespace App {
}
export interface AppConfig {
gpu?: AI.ModelGPUConfig;
appKey: string;
name: string;
httpPort: number;
@@ -322,7 +318,6 @@ export namespace App {
modelAccountBaseURL: string;
params: InstallParams[];
rawCompose?: string;
isEdit?: boolean;
cpuQuota: number;
memoryLimit: number;
memoryUnit: string;
@@ -346,7 +341,6 @@ export namespace App {
export interface AppUpdateVersionReq {
appInstallID: number;
updateVersion?: string;
loadCompose?: boolean;
}
export interface AppIgnoreReq {
+3
View File
@@ -136,6 +136,9 @@ export namespace Container {
mode: string;
}
export interface ContainerInfo {
latestVersion?: string;
targetImage?: string;
hasUpdate?: boolean;
containerID: string;
name: string;
imageName: string;
-1
View File
@@ -151,7 +151,6 @@ export namespace Dashboard {
inodesUsedPercent: number;
}
export interface GPUInfo {
sharedMemory?: boolean;
type: string;
index: number;
npuIndex: number;
+1
View File
@@ -97,6 +97,7 @@ export namespace Setting {
complexityVerification: string;
noAuthSetting: string;
proxyType: string;
proxyEnabled: boolean;
scriptSync: string;
dashboardMemoVisible: string;

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