mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-30 08:05:43 +00:00
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
func appCacheTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cache.db")), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}, &model.Setting{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
|
|
global.DB = db
|
|
global.Dir = global.SystemDir{AppResourceDir: t.TempDir()}
|
|
global.Dir.RemoteAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceRemote)
|
|
global.Dir.LocalAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceLocal)
|
|
global.Dir.CustomAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceCustom)
|
|
global.LOG = logrus.New()
|
|
global.LOG.SetOutput(io.Discard)
|
|
t.Cleanup(func() {
|
|
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
|
|
sqlDB, err := db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
if err := db.Create(&model.Setting{Key: "AppStoreLastModified", Value: "42"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func appCacheTestArchive(t *testing.T, name, content string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.WriteString(tw, content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func appCacheTestWrite(t *testing.T, name, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAppDownloadURLPreservesSourceQuery(t *testing.T) {
|
|
for _, rawURL := range []string{
|
|
"https://apps.1panel.pro/php/8/php-8.tar.gz",
|
|
"https://apps.1panel.pro/php/8/php-8.tar.gz?token=a%2Bb®ion=intl&v=old#archive",
|
|
} {
|
|
t.Run(rawURL, func(t *testing.T) {
|
|
got, err := appDownloadURL(rawURL, "42 /next")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed, err := url.Parse(got)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original, _ := url.Parse(rawURL)
|
|
wantQuery := original.Query()
|
|
wantQuery.Set("v", "42 /next")
|
|
if !reflect.DeepEqual(parsed.Query(), wantQuery) || parsed.Path != original.Path || parsed.Fragment != original.Fragment {
|
|
t.Fatalf("download URL lost source fields: %s", got)
|
|
}
|
|
})
|
|
}
|
|
if _, err := appDownloadURL("https://apps.1panel.pro/%zz", "42"); err == nil {
|
|
t.Fatal("invalid source URL was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDownloadAppRefreshesAllOfficialCachedPackages(t *testing.T) {
|
|
db := appCacheTestDB(t)
|
|
app := model.App{Key: "mysql", Name: "MySQL", Type: "website", Resource: constant.AppResourceRemote}
|
|
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
|
dataPath := filepath.Join(versionDir, "data.yml")
|
|
stampPath := filepath.Join(versionDir, ".1panel-appstore-version")
|
|
appCacheTestWrite(t, dataPath, "stale")
|
|
archives := map[string][]byte{
|
|
"42": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-42"),
|
|
"43": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-43"),
|
|
}
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Query().Get("token") != "a+b" || r.URL.Query().Get("region") != "intl" {
|
|
t.Errorf("source query was lost: %s", r.URL.RawQuery)
|
|
}
|
|
version := r.URL.Query().Get("v")
|
|
if version != "42" && version != "43" {
|
|
t.Errorf("unexpected app store version: %q", version)
|
|
}
|
|
_, _ = w.Write(archives[version])
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz?token=a%2Bb®ion=intl", Update: false}
|
|
if err := db.Create(&detail).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, step := range []struct {
|
|
version string
|
|
calls int32
|
|
update bool
|
|
}{
|
|
{"42", 1, false}, // An existing directory without a stamp must be refreshed.
|
|
{"42", 1, false},
|
|
{"43", 2, false},
|
|
{"43", 3, true},
|
|
} {
|
|
if err := db.Model(&model.Setting{}).Where("key = ?", "AppStoreLastModified").Update("value", step.version).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
detail.Update = step.update
|
|
if err := db.Model(&detail).UpdateColumn("update", detail.Update).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := downloadApp(app, detail, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != step.calls {
|
|
t.Fatalf("version %s: HTTP calls = %d, want %d", step.version, calls.Load(), step.calls)
|
|
}
|
|
for name, want := range map[string]string{dataPath: "fresh-" + step.version, stampPath: step.version} {
|
|
data, err := os.ReadFile(name)
|
|
if err != nil || string(data) != want {
|
|
t.Fatalf("%s = %q, %v; want %q", name, data, err, want)
|
|
}
|
|
}
|
|
var stored model.AppDetail
|
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
|
t.Fatalf("refresh altered source URL or left update flag: %+v", stored)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDownloadAppFailureKeepsCacheVersionAndUpdateFlag(t *testing.T) {
|
|
for _, failure := range []string{"download", "decompress"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
db := appCacheTestDB(t)
|
|
app := model.App{Key: "mysql", Resource: constant.AppResourceRemote}
|
|
stampPath := filepath.Join(app.GetAppResourcePath(), "8", ".1panel-appstore-version")
|
|
appCacheTestWrite(t, stampPath, "41")
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if failure == "download" {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, "not an archive")
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz", Update: true}
|
|
if err := db.Create(&detail).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
install := model.AppInstall{}
|
|
if err := downloadApp(app, detail, &install, nil); err == nil {
|
|
t.Fatal("failed refresh was accepted")
|
|
}
|
|
if install.Status != constant.StatusDownloadErr || install.Message == "" {
|
|
t.Fatalf("installation did not report refresh failure: %+v", install)
|
|
}
|
|
stamp, err := os.ReadFile(stampPath)
|
|
if err != nil || string(stamp) != "41" {
|
|
t.Fatalf("failed refresh updated stamp: %q, %v", stamp, err)
|
|
}
|
|
var stored model.AppDetail
|
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stored.Update || stored.DownloadUrl != detail.DownloadUrl {
|
|
t.Fatalf("failed refresh changed database state: %+v", stored)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetPHPAppDetailUsesSynchronizedOfficialParams(t *testing.T) {
|
|
const oldYAML = "additionalProperties:\n formFields:\n - envKey: PHP_VERSION\n type: select\n default: 8.1.34\n"
|
|
const newJSON = `{"formFields":[{"envKey":"PHP_VERSION","type":"select","default":"8.1.35"}]}`
|
|
const compose = "services:\n php:\n image: php:test\n"
|
|
for _, tc := range []struct {
|
|
name, resource, compose string
|
|
update bool
|
|
}{
|
|
{"remote", constant.AppResourceRemote, compose, false},
|
|
{"local", constant.AppResourceLocal, compose, false},
|
|
{"custom", constant.AppResourceCustom, compose, false},
|
|
{"remote-fetches-compose", constant.AppResourceRemote, "", true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db := appCacheTestDB(t)
|
|
app := model.App{Key: "php", Name: "PHP", Type: constant.RuntimePHP, Resource: tc.resource}
|
|
if err := db.Omit(clause.Associations).Create(&app).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
|
|
appCacheTestWrite(t, filepath.Join(versionDir, "data.yml"), oldYAML)
|
|
appCacheTestWrite(t, filepath.Join(versionDir, "docker-compose.yml"), compose)
|
|
archive := appCacheTestArchive(t, "php/8/data.yml", oldYAML)
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Query().Get("v") != "42" {
|
|
t.Errorf("missing app store version: %s", r.URL.RawQuery)
|
|
}
|
|
if r.URL.Path == "/docker-compose.yml" {
|
|
_, _ = io.WriteString(w, compose)
|
|
return
|
|
}
|
|
_, _ = w.Write(archive)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
detail := model.AppDetail{AppId: app.ID, Version: "8", Params: newJSON, DockerCompose: tc.compose, Update: tc.update, DownloadUrl: server.URL + "/php-8.tar.gz"}
|
|
if err := db.Create(&detail).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := (AppService{}).GetAppDetail(app.ID, "8", "runtime")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantVersion, wantCalls := "8.1.34", int32(0)
|
|
if tc.resource == constant.AppResourceRemote {
|
|
wantVersion, wantCalls = "8.1.35", 1
|
|
}
|
|
want := map[string]interface{}{"formFields": []interface{}{map[string]interface{}{
|
|
"envKey": "PHP_VERSION", "type": "select", "default": wantVersion,
|
|
}}}
|
|
if !reflect.DeepEqual(got.Params, want) || got.Image != "php" || calls.Load() != wantCalls {
|
|
encoded, _ := json.Marshal(got.Params)
|
|
t.Fatalf("params=%s, image=%s, HTTP calls=%d; want version %s, calls %d", encoded, got.Image, calls.Load(), wantVersion, wantCalls)
|
|
}
|
|
var stored model.AppDetail
|
|
if err := db.First(&stored, detail.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Update || got.Update {
|
|
t.Fatal("fetching the compose file restored a stale update flag")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessOneAppDefersComposeUntilRequested(t *testing.T) {
|
|
db := appCacheTestDB(t)
|
|
const compose = "services:\n php:\n image: php:test\n"
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Path != "/php/8/docker-compose.yml" || r.URL.Query().Get("v") != "42" {
|
|
t.Errorf("unversioned initial compose request: %s", r.URL.String())
|
|
}
|
|
_, _ = io.WriteString(w, compose)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: constant.AppResourceRemote}
|
|
if err := db.Create(&app).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := appSyncContext{
|
|
baseRemoteUrl: server.URL,
|
|
systemVersion: "2.4.0",
|
|
list: &dto.AppList{LastModified: 42},
|
|
}
|
|
result := ctx.processOneApp(appWorkItem{
|
|
app: app,
|
|
appDef: dto.AppDefine{
|
|
AppProperty: dto.AppProperty{Key: "php", Type: constant.RuntimePHP},
|
|
Versions: []dto.AppConfigVersion{{Name: "8", AppForm: dto.AppForm{
|
|
FormFields: []dto.AppFormFields{{EnvKey: "PHP_VERSION", Default: "8.1.35", Type: "select"}},
|
|
}}},
|
|
},
|
|
})
|
|
if calls.Load() != 0 || len(result.Details) != 1 {
|
|
t.Fatalf("initial compose refresh failed: calls=%d, result=%+v", calls.Load(), result)
|
|
}
|
|
detail := result.Details[0]
|
|
if detail.DockerCompose != "" || detail.DownloadUrl != server.URL+"/php/8/php-8.tar.gz" {
|
|
t.Fatalf("sync fetched compose or changed the canonical archive URL: %+v", detail)
|
|
}
|
|
detail.AppId = app.ID
|
|
if err := db.Create(&detail).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 2 {
|
|
if err := ensureAppDetailCompose(app, &detail); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != 1 || detail.DockerCompose != compose {
|
|
t.Fatalf("on-demand compose did not download once and cache: calls=%d, detail=%+v", calls.Load(), detail)
|
|
}
|
|
}
|
|
}
|