From a4ff938aafd179e8393323f75ffa399192b4ab0c Mon Sep 17 00:00:00 2001 From: Snrat Date: Sat, 18 Jul 2026 15:31:58 +0000 Subject: [PATCH 1/8] feat: support dynamic module build for OpenResty --- agent/app/dto/nginx.go | 44 +- agent/app/dto/request/nginx.go | 21 +- agent/app/dto/response/nginx.go | 19 +- agent/app/service/app_utils.go | 156 ++- agent/app/service/nginx.go | 210 ++-- agent/app/service/nginx_module.go | 1120 +++++++++++++++++ agent/app/service/nginx_module_test.go | 303 +++++ scripts/openresty-modules/README.md | 168 +++ scripts/openresty-modules/diagnose-install.sh | 475 +++++++ scripts/openresty-modules/test-builder.sh | 535 ++++++++ 10 files changed, 2880 insertions(+), 171 deletions(-) create mode 100644 agent/app/service/nginx_module.go create mode 100644 agent/app/service/nginx_module_test.go create mode 100644 scripts/openresty-modules/README.md create mode 100644 scripts/openresty-modules/diagnose-install.sh create mode 100644 scripts/openresty-modules/test-builder.sh diff --git a/agent/app/dto/nginx.go b/agent/app/dto/nginx.go index 36308105d0aa..7d3ccfd3778b 100644 --- a/agent/app/dto/nginx.go +++ b/agent/app/dto/nginx.go @@ -1,6 +1,8 @@ package dto import ( + "time" + "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/utils/nginx/components" ) @@ -87,9 +89,41 @@ var LBAlgorithms = map[string]struct{}{"ip_hash": {}, "least_conn": {}} var RealIPKeys = map[string]struct{}{"X-Forwarded-For": {}, "X-Real-IP": {}, "CF-Connecting-IP": {}} type NginxModule struct { - Name string `json:"name"` - Script string `json:"script"` - Packages []string `json:"packages"` - Params string `json:"params"` - Enable bool `json:"enable"` + Name string `json:"name"` + Script string `json:"script"` + Packages []string `json:"packages"` + Params string `json:"params"` + Enable bool `json:"enable"` + Deleted bool `json:"deleted,omitempty"` + BuildMode string `json:"buildMode,omitempty"` + Provider string `json:"provider,omitempty"` + DynamicSupport string `json:"dynamicSupport,omitempty"` + LoadOrder int `json:"loadOrder,omitempty"` + Builds []NginxModuleBuild `json:"builds,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type NginxModuleBuild struct { + Provider string `json:"provider"` + Status string `json:"status"` + Hash string `json:"hash"` + Target NginxModuleTarget `json:"target"` + Artifacts []NginxModuleArtifact `json:"artifacts,omitempty"` + Error string `json:"error,omitempty"` + BuiltAt time.Time `json:"builtAt,omitempty"` +} + +type NginxModuleTarget struct { + Key string `json:"key"` + OpenRestyVersion string `json:"openrestyVersion"` + Architecture string `json:"architecture"` + Image string `json:"image,omitempty"` + ImageDigest string `json:"imageDigest,omitempty"` + BuilderDigest string `json:"builderDigest,omitempty"` +} + +type NginxModuleArtifact struct { + Name string `json:"name"` + Path string `json:"path"` + Checksum string `json:"checksum"` } diff --git a/agent/app/dto/request/nginx.go b/agent/app/dto/request/nginx.go index 7c70613b9ca5..9624e00d4547 100644 --- a/agent/app/dto/request/nginx.go +++ b/agent/app/dto/request/nginx.go @@ -114,17 +114,22 @@ type NginxRedirectUpdate struct { } type NginxBuildReq struct { - TaskID string `json:"taskID" validate:"required"` - Mirror string `json:"mirror" validate:"required"` + TaskID string `json:"taskID" validate:"required"` + Mirror string `json:"mirror" validate:"required"` + Modules []string `json:"modules"` + Force bool `json:"force"` } type NginxModuleUpdate struct { - Operate string `json:"operate" validate:"required,oneof=create delete update"` - Name string `json:"name" validate:"required"` - Script string `json:"script"` - Packages string `json:"packages"` - Enable bool `json:"enable"` - Params string `json:"params"` + Operate string `json:"operate" validate:"required,oneof=create delete update"` + Name string `json:"name" validate:"required"` + Script string `json:"script"` + Packages string `json:"packages"` + Enable bool `json:"enable"` + Params string `json:"params"` + BuildMode string `json:"buildMode" validate:"omitempty,oneof=auto dynamic static"` + Provider string `json:"provider" validate:"omitempty,oneof=local prebuilt"` + LoadOrder int `json:"loadOrder" validate:"omitempty,min=0,max=9999"` } type NginxOperateReq struct { diff --git a/agent/app/dto/response/nginx.go b/agent/app/dto/response/nginx.go index 1e0b0e0a940e..665b767b7d01 100644 --- a/agent/app/dto/response/nginx.go +++ b/agent/app/dto/response/nginx.go @@ -69,11 +69,20 @@ type NginxProxyCache struct { } type NginxModule struct { - Name string `json:"name"` - Script string `json:"script"` - Packages string `json:"packages"` - Params string `json:"params"` - Enable bool `json:"enable"` + Name string `json:"name"` + Script string `json:"script"` + Packages string `json:"packages"` + Params string `json:"params"` + Enable bool `json:"enable"` + BuildMode string `json:"buildMode"` + Provider string `json:"provider"` + DynamicSupport string `json:"dynamicSupport"` + LoadOrder int `json:"loadOrder"` + BuildStatus string `json:"buildStatus"` + LoadStatus string `json:"loadStatus"` + Compatibility string `json:"compatibility"` + Artifacts []dto.NginxModuleArtifact `json:"artifacts"` + LastError string `json:"lastError"` } type NginxBuildConfig struct { diff --git a/agent/app/service/app_utils.go b/agent/app/service/app_utils.go index 64175c15d6e0..4696c423bbdf 100644 --- a/agent/app/service/app_utils.go +++ b/agent/app/service/app_utils.go @@ -1,7 +1,6 @@ package service import ( - "bufio" "context" "encoding/base64" "encoding/json" @@ -653,11 +652,58 @@ func handleUpgradeCompose(install model.AppInstall, detail model.AppDetail) (map if oldServiceValue["restart"] != nil { serviceValue["restart"] = oldServiceValue["restart"] } + if install.App.Key == constant.AppOpenresty { + mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue) + } servicesMap[install.ServiceName] = serviceValue composeMap["services"] = servicesMap return composeMap, nil } +// mergeOpenrestyModuleVolumes carries the dynamic module mounts of the old +// compose over to the upgraded one when it does not declare them, so built +// module artifacts and their load configuration stay mounted across upgrades. +func mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue map[string]interface{}) { + oldVolumes, ok := oldServiceValue["volumes"].([]interface{}) + if !ok { + return + } + newVolumes, _ := serviceValue["volumes"].([]interface{}) + existing := make(map[string]struct{}, len(newVolumes)) + for _, volume := range newVolumes { + if containerPath, ok := composeVolumeContainerPath(volume); ok { + existing[containerPath] = struct{}{} + } + } + for _, volume := range oldVolumes { + containerPath, ok := composeVolumeContainerPath(volume) + if !ok { + continue + } + if !strings.Contains(containerPath, "modules-enabled") && !strings.Contains(containerPath, "nginx/modules/1panel") { + continue + } + if _, ok = existing[containerPath]; ok { + continue + } + newVolumes = append(newVolumes, volume) + existing[containerPath] = struct{}{} + } + serviceValue["volumes"] = newVolumes +} + +func composeVolumeContainerPath(volume interface{}) (string, bool) { + volumeStr, ok := volume.(string) + if !ok { + return "", false + } + parts := strings.Split(volumeStr, ":") + if len(parts) < 2 { + return "", false + } + return parts[1], true +} + func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string, error) { if detail.DockerCompose == "" { return "", nil @@ -691,74 +737,35 @@ func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string return string(composeByte), nil } -func buildNginx(parentTask *task.Task) error { - nginxInstall, err := getAppInstallByKey(constant.AppOpenresty) - if err != nil { - return err - } +func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall) error { fileOp := files.NewFileOp() buildPath := path.Join(nginxInstall.GetPath(), "build") if !fileOp.Stat(buildPath) { return buserr.New("ErrBuildDirNotFound") } - moduleConfigPath := path.Join(buildPath, "module.json") - moduleContent, err := fileOp.GetContent(moduleConfigPath) + modules, err := loadNginxModules(nginxInstall) if err != nil { return err } - var ( - modules []dto.NginxModule - addModuleParams []string - addPackages []string - ) - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) - bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm) - if err != nil { - return err - } - defer bashFile.Close() - bashFileWriter := bufio.NewWriter(bashFile) - for _, module := range modules { - if !module.Enable { - continue - } - _, err = bashFileWriter.WriteString(module.Script + "\n") - if err != nil { - return err - } - addModuleParams = append(addModuleParams, module.Params) - addPackages = append(addPackages, module.Packages...) - } - err = bashFileWriter.Flush() - if err != nil { + previousModules := cloneNginxModules(modules) + staticBuild := hasEnabledStaticNginxModules(modules) + if err = configureStaticNginxModules(nginxInstall, modules, ""); err != nil { + return err + } + if staticBuild { + logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image")) + parentTask.LogStart(logStr) + cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute)) + if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { return err } + parentTask.LogSuccess(logStr) } - envs, err := gotenv.Read(nginxInstall.GetEnvPath()) + modules, err = buildDynamicNginxModules(nginxInstall, modules, nil, false, parentTask) if err != nil { return err } - envs["RESTY_CONFIG_OPTIONS_MORE"] = "" - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = "" - if len(addModuleParams) > 0 { - envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ") - } - if len(addPackages) > 0 { - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ") - } - _ = gotenv.Write(envs, nginxInstall.GetEnvPath()) - if len(addModuleParams) == 0 && len(addPackages) == 0 { - return nil - } - logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image")) - parentTask.LogStart(logStr) - cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(60*time.Minute)) - if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { - return err - } - parentTask.LogSuccess(logStr) - return nil + return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false) } func upgradeInstall(req request.AppInstallUpgrade) error { @@ -874,6 +881,16 @@ func upgradeInstall(req request.AppInstallUpgrade) error { if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil { return err } + if fileOp.Stat(path.Join(detailBuildDir, "Dockerfile.modules")) { + if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile.modules"), installBuildDir); err != nil { + return err + } + } + if fileOp.Stat(path.Join(detailBuildDir, "module.catalog.json")) { + if err := fileOp.CopyFile(path.Join(detailBuildDir, "module.catalog.json"), installBuildDir); err != nil { + return err + } + } if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil { return err } @@ -951,6 +968,33 @@ func upgradeInstall(req request.AppInstallUpgrade) error { } } + if install.App.Key == constant.AppOpenresty { + modules, moduleErr := loadNginxModules(install) + if moduleErr != nil { + return moduleErr + } + // Build dynamic modules for the target version before stopping the + // current container. Static modules retain the full rebuild path. + if !hasEnabledStaticNginxModules(modules) { + previousModules := cloneNginxModules(modules) + modules, moduleErr = buildDynamicNginxModules(install, modules, nil, false, t) + if moduleErr != nil { + var buildFailed *nginxModuleBuildFailedError + if !errors.As(moduleErr, &buildFailed) || !fallbackNginxModuleToStatic(modules, buildFailed) { + return moduleErr + } + // The flipped module makes buildNginx below take the static + // chain; if that fails the upgrade fails with it, so unlike + // the UI build entry there is no restore-to-auto step here. + t.Logf("WARNING: dynamic build of module %s failed: %v; falling back to static build", buildFailed.Module, buildFailed.Err) + } + if moduleErr = saveNginxModules(install, modules); moduleErr != nil { + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return moduleErr + } + } + } + if out, err := compose.Down(install.GetComposePath()); err != nil { if out != "" { upErr = errors.New(out) @@ -985,7 +1029,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error { } if install.App.Key == constant.AppOpenresty { - if err = buildNginx(t); err != nil { + if err = buildNginx(t, install); err != nil { t.Log(err.Error()) return err } diff --git a/agent/app/service/nginx.go b/agent/app/service/nginx.go index d5c0446169a8..1475115b3783 100644 --- a/agent/app/service/nginx.go +++ b/agent/app/service/nginx.go @@ -1,8 +1,6 @@ package service import ( - "bufio" - "encoding/json" "fmt" "github.com/1Panel-dev/1Panel/agent/utils/nginx" "github.com/1Panel-dev/1Panel/agent/utils/nginx/parser" @@ -17,7 +15,6 @@ import ( "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/utils/cmd" "github.com/subosito/gotenv" "github.com/1Panel-dev/1Panel/agent/utils/compose" @@ -181,71 +178,20 @@ func (n NginxService) Build(req request.NginxBuildReq) error { if err = task.CheckTaskIsExecuting(taskName); err != nil { return err } - fileOp := files.NewFileOp() - buildPath := path.Join(nginxInstall.GetPath(), "build") - if !fileOp.Stat(buildPath) { - return buserr.New("ErrBuildDirNotFound") - } - moduleConfigPath := path.Join(buildPath, "module.json") - moduleContent, err := fileOp.GetContent(moduleConfigPath) - if err != nil { - return err - } - var ( - modules []dto.NginxModule - addModuleParams []string - addPackages []string - ) - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) - bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm) - if err != nil { - return err - } - defer bashFile.Close() - bashFileWriter := bufio.NewWriter(bashFile) - for _, module := range modules { - if !module.Enable { - continue - } - _, err = bashFileWriter.WriteString(module.Script + "\n") - if err != nil { - return err - } - addModuleParams = append(addModuleParams, module.Params) - addPackages = append(addPackages, module.Packages...) - } - err = bashFileWriter.Flush() - if err != nil { - return err - } - } - envs, err := gotenv.Read(nginxInstall.GetEnvPath()) - if err != nil { + if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, nginxInstall.ID); err != nil { return err } - envs["CONTAINER_PACKAGE_URL"] = req.Mirror - envs["RESTY_CONFIG_OPTIONS_MORE"] = "" - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = "" - if len(addModuleParams) > 0 { - envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ") - } - if len(addPackages) > 0 { - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ") + buildPath := path.Join(nginxInstall.GetPath(), "build") + if !files.NewFileOp().Stat(buildPath) { + return buserr.New("ErrBuildDirNotFound") } - _ = gotenv.Write(envs, nginxInstall.GetEnvPath()) buildTask, err := task.NewTaskWithOps(nginxInstall.Name, task.TaskBuild, task.TaskScopeApp, req.TaskID, nginxInstall.ID) if err != nil { return err } buildTask.AddSubTaskWithOps("", func(t *task.Task) error { - cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*buildTask), cmd.WithTimeout(120*time.Minute)) - if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { - return err - } - _, err = compose.DownAndUp(nginxInstall.GetComposePath()) - return err + return executeNginxModuleBuild(nginxInstall, req.Modules, req.Force, req.Mirror, t, true) }, nil, 0, 120*time.Minute) go func() { @@ -259,27 +205,65 @@ func (n NginxService) GetModules() (*response.NginxBuildConfig, error) { if err != nil { return nil, err } - fileOp := files.NewFileOp() - var modules []dto.NginxModule - moduleConfigPath := path.Join(nginxInstall.GetPath(), "build", "module.json") - if !fileOp.Stat(moduleConfigPath) { - return nil, nil - } - moduleContent, err := fileOp.GetContent(moduleConfigPath) + modules, err := loadNginxModules(nginxInstall) if err != nil { return nil, err } - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) + target, targetWarning, targetErr := resolveNginxModuleTarget(nginxInstall) + if targetWarning != "" { + global.LOG.Warn(targetWarning) } var resList []response.NginxModule for _, module := range modules { + if module.Deleted { + continue + } + buildStatus := nginxModuleStatusPending + loadStatus := "disabled" + compatibility := "unknown" + var artifacts []dto.NginxModuleArtifact + if module.BuildMode == nginxModuleBuildStatic { + buildStatus = nginxModuleStatusReady + compatibility = "static" + if module.Enable { + loadStatus = "enabled" + } + } else if targetErr == nil { + if build := findCurrentNginxModuleBuild(module, target); build != nil { + buildStatus = build.Status + artifacts = build.Artifacts + if build.Status == nginxModuleStatusReady { + compatibility = "compatible" + if module.Enable { + loadStatus = "enabled" + } + } + } else if latestBuild := findLatestNginxModuleBuild(module, target); latestBuild != nil { + compatibility = "stale" + artifacts = latestBuild.Artifacts + if module.Enable { + loadStatus = "enabled" + } + } + } + if module.BuildMode != nginxModuleBuildStatic && module.LastError != "" { + buildStatus = nginxModuleStatusFailed + } resList = append(resList, response.NginxModule{ - Name: module.Name, - Script: module.Script, - Packages: strings.Join(module.Packages, ","), - Params: module.Params, - Enable: module.Enable, + Name: module.Name, + Script: module.Script, + Packages: strings.Join(module.Packages, ","), + Params: module.Params, + Enable: module.Enable, + BuildMode: module.BuildMode, + Provider: module.Provider, + DynamicSupport: module.DynamicSupport, + LoadOrder: module.LoadOrder, + BuildStatus: buildStatus, + LoadStatus: loadStatus, + Compatibility: compatibility, + Artifacts: artifacts, + LastError: module.LastError, }) } envs, err := gotenv.Read(nginxInstall.GetEnvPath()) @@ -298,59 +282,91 @@ func (n NginxService) UpdateModule(req request.NginxModuleUpdate) error { if err != nil { return err } - fileOp := files.NewFileOp() - var ( - modules []dto.NginxModule - ) - moduleConfigPath := path.Join(nginxInstall.GetPath(), "build", "module.json") - if !fileOp.Stat(moduleConfigPath) { - _ = fileOp.CreateFile(moduleConfigPath) - } - moduleContent, err := fileOp.GetContent(moduleConfigPath) - if err != nil { + if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, nginxInstall.ID); err != nil { return err } - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) + modules, err := loadNginxModules(nginxInstall) + if err != nil { + return err } + oldModules := cloneNginxModules(modules) + var deletedModule *dto.NginxModule switch req.Operate { case "create": - for _, module := range modules { + recreated := false + for i, module := range modules { if module.Name == req.Name { + if module.Deleted { + modules[i] = dto.NginxModule{ + Name: req.Name, Script: req.Script, Packages: strings.Split(req.Packages, ","), + Params: req.Params, + Enable: req.Enable, BuildMode: req.BuildMode, Provider: req.Provider, LoadOrder: req.LoadOrder, + } + recreated = true + break + } return buserr.New("ErrNameIsExist") } } - modules = append(modules, dto.NginxModule{ - Name: req.Name, - Script: req.Script, - Packages: strings.Split(req.Packages, ","), - Params: req.Params, - Enable: true, - }) + if !recreated { + modules = append(modules, dto.NginxModule{ + Name: req.Name, + Script: req.Script, + Packages: strings.Split(req.Packages, ","), + Params: req.Params, + Enable: req.Enable, + BuildMode: req.BuildMode, + Provider: req.Provider, + LoadOrder: req.LoadOrder, + }) + } case "update": + found := false for i, module := range modules { if module.Name == req.Name { + found = true modules[i].Script = req.Script modules[i].Packages = strings.Split(req.Packages, ",") modules[i].Params = req.Params modules[i].Enable = req.Enable + modules[i].BuildMode = req.BuildMode + modules[i].Provider = req.Provider + modules[i].LoadOrder = req.LoadOrder break } } + if !found { + return fmt.Errorf("OpenResty module %s not found", req.Name) + } case "delete": + found := false for i, module := range modules { if module.Name == req.Name { - modules = append(modules[:i], modules[i+1:]...) + found = true + moduleCopy := module + deletedModule = &moduleCopy + modules[i].Deleted = true + modules[i].Enable = false break } } + if !found { + return fmt.Errorf("OpenResty module %s not found", req.Name) + } } - moduleByte, err := json.Marshal(modules) - if err != nil { + if err = saveNginxModules(nginxInstall, modules); err != nil { + return err + } + if err = reconcileDynamicNginxModuleConfig(nginxInstall, modules, true); err != nil { + _ = saveNginxModules(nginxInstall, oldModules) + _ = reconcileDynamicNginxModuleConfig(nginxInstall, oldModules, false) return err } - return fileOp.SaveFileWithByte(moduleConfigPath, moduleByte, constant.DirPerm) + if deletedModule != nil { + _ = removeNginxModuleArtifacts(nginxInstall, *deletedModule) + } + return nil } func (n NginxService) OperateDefaultHTTPs(req request.NginxDefaultHTTPSUpdate) error { diff --git a/agent/app/service/nginx_module.go b/agent/app/service/nginx_module.go new file mode 100644 index 000000000000..6825276b34d0 --- /dev/null +++ b/agent/app/service/nginx_module.go @@ -0,0 +1,1120 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/cmd" + "github.com/1Panel-dev/1Panel/agent/utils/compose" + dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/1Panel-dev/1Panel/agent/utils/files" + "github.com/mattn/go-shellwords" + "github.com/subosito/gotenv" +) + +const ( + nginxModuleBuildAuto = "auto" + nginxModuleBuildDynamic = "dynamic" + nginxModuleBuildStatic = "static" + + nginxModuleProviderLocal = "local" + + nginxModuleSupportUnknown = "unknown" + nginxModuleSupportSupported = "supported" + + nginxModuleStatusPending = "pending" + nginxModuleStatusReady = "ready" + nginxModuleStatusFailed = "failed" + + nginxModuleContainerRoot = "/usr/local/openresty/nginx/modules/1panel" + nginxModuleConfigPrefix = "1panel-module-" +) + +var ( + nginxModulePackagePattern = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9+.-]*$") + nginxModuleArtifactPattern = regexp.MustCompile(`^[a-zA-Z0-9_./+-]+\.so$`) + nginxModuleChecksumPattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) + + errNginxModuleBuilderMissing = errors.New("dynamic module builder not found") +) + +type nginxModuleBuildSpec struct { + Install model.AppInstall + Module dto.NginxModule + Target dto.NginxModuleTarget + BuildPath string + Force bool + Task *task.Task +} + +// nginxModuleArtifactProvider keeps local builds replaceable by a future +// precompiled-artifact resolver without changing module state or API contracts. +type nginxModuleArtifactProvider interface { + Name() string + Resolve(spec nginxModuleBuildSpec) (dto.NginxModuleBuild, error) +} + +type localNginxModuleProvider struct{} + +func (localNginxModuleProvider) Name() string { + return nginxModuleProviderLocal +} + +func normalizeNginxModule(module *dto.NginxModule) { + if module.BuildMode == "" { + // Entries created before the dynamic-module schema must preserve their old behavior. + module.BuildMode = nginxModuleBuildStatic + } + if module.Provider == "" { + module.Provider = nginxModuleProviderLocal + } + if module.DynamicSupport == "" { + module.DynamicSupport = nginxModuleSupportUnknown + } + if module.LoadOrder == 0 { + module.LoadOrder = 50 + } + module.Packages = compactStrings(module.Packages) +} + +func compactStrings(items []string) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + +func loadNginxModules(install model.AppInstall) ([]dto.NginxModule, error) { + modulePath := path.Join(install.GetPath(), "build", "module.json") + modules, err := readNginxModuleFile(modulePath) + if err != nil { + return nil, err + } + catalogPath := path.Join(install.GetPath(), "build", "module.catalog.json") + catalog, err := readNginxModuleFile(catalogPath) + if err != nil { + return nil, err + } + moduleIndexes := make(map[string]int, len(modules)) + for i := range modules { + moduleIndexes[modules[i].Name] = i + } + for _, catalogModule := range catalog { + if index, ok := moduleIndexes[catalogModule.Name]; ok { + if modules[index].Provider == "" { + modules[index].Provider = catalogModule.Provider + } + if modules[index].DynamicSupport == "" { + modules[index].DynamicSupport = catalogModule.DynamicSupport + } + if modules[index].LoadOrder == 0 { + modules[index].LoadOrder = catalogModule.LoadOrder + } + continue + } + modules = append(modules, catalogModule) + } + for i := range modules { + normalizeNginxModule(&modules[i]) + } + return modules, nil +} + +func readNginxModuleFile(filePath string) ([]dto.NginxModule, error) { + content, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []dto.NginxModule{}, nil + } + return nil, err + } + if len(strings.TrimSpace(string(content))) == 0 { + return []dto.NginxModule{}, nil + } + var modules []dto.NginxModule + if err = json.Unmarshal(content, &modules); err != nil { + return nil, fmt.Errorf("parse OpenResty module configuration %s: %w", filePath, err) + } + return modules, nil +} + +func saveNginxModules(install model.AppInstall, modules []dto.NginxModule) error { + for i := range modules { + normalizeNginxModule(&modules[i]) + } + content, err := json.MarshalIndent(modules, "", " ") + if err != nil { + return err + } + modulePath := path.Join(install.GetPath(), "build", "module.json") + if err = os.MkdirAll(path.Dir(modulePath), constant.DirPerm); err != nil { + return err + } + tmpPath := modulePath + ".tmp" + if err = os.WriteFile(tmpPath, content, constant.FilePerm); err != nil { + return err + } + return os.Rename(tmpPath, modulePath) +} + +func resolveNginxModuleTarget(install model.AppInstall) (dto.NginxModuleTarget, string, error) { + builderPath := path.Join(install.GetPath(), "build", "Dockerfile.modules") + builderContent, err := os.ReadFile(builderPath) + if err != nil { + return dto.NginxModuleTarget{}, "", fmt.Errorf("%w: %v", errNginxModuleBuilderMissing, err) + } + builderSum := sha256.Sum256(builderContent) + target := dto.NginxModuleTarget{ + OpenRestyVersion: install.Version, + Architecture: runtime.GOARCH, + BuilderDigest: hex.EncodeToString(builderSum[:]), + } + envContent, _ := os.ReadFile(install.GetEnvPath()) + images, imageErr := dockerUtils.GetImagesFromDockerCompose(envContent, []byte(install.DockerCompose)) + if imageErr != nil || len(images) == 0 { + images, imageErr = compose.GetComposeImages(install.GetComposePath()) + } + var warning string + if imageErr == nil && len(images) > 0 { + target.Image = images[0] + inspectMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute)) + inspectOut, inspectErr := inspectMgr.RunWithStdout("docker", "image", "inspect", "--format={{.Id}}\t{{.Architecture}}", target.Image) + if inspectErr != nil { + warning = fmt.Sprintf("inspect OpenResty image %s failed, module rebuilds will fall back to host architecture %s: %v", target.Image, target.Architecture, inspectErr) + } else if fields := strings.Fields(inspectOut); len(fields) >= 2 { + target.ImageDigest = fields[0] + target.Architecture = fields[1] + } + } + keyInput := strings.Join([]string{target.OpenRestyVersion, target.Architecture, target.ImageDigest, target.BuilderDigest}, "\x00") + keySum := sha256.Sum256([]byte(keyInput)) + target.Key = fmt.Sprintf("%s-%s-%s", sanitizeModulePathPart(target.OpenRestyVersion), target.Architecture, hex.EncodeToString(keySum[:6])) + return target, warning, nil +} + +func sanitizeModulePathPart(value string) string { + var result strings.Builder + for _, char := range value { + if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '-' || char == '_' { + result.WriteRune(char) + } else { + result.WriteByte('-') + } + } + return strings.Trim(result.String(), "-") +} + +func nginxModulePathName(value string) string { + base := sanitizeModulePathPart(value) + if base == "" { + base = "module" + } + if len(base) > 48 { + base = base[:48] + } + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%s-%s", base, hex.EncodeToString(sum[:4])) +} + +func shortNginxModuleHash(value string) string { + if len(value) <= 12 { + return value + } + return value[:12] +} + +func findLatestNginxModuleBuild(module dto.NginxModule, target dto.NginxModuleTarget) *dto.NginxModuleBuild { + var latest *dto.NginxModuleBuild + for i := range module.Builds { + if module.Builds[i].Target.Key != target.Key || module.Builds[i].Status != nginxModuleStatusReady { + continue + } + if latest == nil || module.Builds[i].BuiltAt.After(latest.BuiltAt) { + latest = &module.Builds[i] + } + } + return latest +} + +func findCurrentNginxModuleBuild(module dto.NginxModule, target dto.NginxModuleTarget) *dto.NginxModuleBuild { + params, err := normalizeDynamicModuleParams(module.Params) + if err != nil { + return nil + } + buildHash, err := nginxModuleBuildHash(module, target, params) + if err != nil { + return nil + } + for i := range module.Builds { + if module.Builds[i].Target.Key == target.Key && module.Builds[i].Hash == buildHash { + return &module.Builds[i] + } + } + return nil +} + +func upsertNginxModuleBuild(module *dto.NginxModule, build dto.NginxModuleBuild) { + for i := range module.Builds { + if module.Builds[i].Target.Key == build.Target.Key && module.Builds[i].Hash == build.Hash { + module.Builds[i] = build + return + } + } + module.Builds = append(module.Builds, build) +} + +func cloneNginxModules(modules []dto.NginxModule) []dto.NginxModule { + cloned := make([]dto.NginxModule, len(modules)) + copy(cloned, modules) + for i := range cloned { + cloned[i].Packages = append([]string(nil), modules[i].Packages...) + cloned[i].Builds = make([]dto.NginxModuleBuild, len(modules[i].Builds)) + copy(cloned[i].Builds, modules[i].Builds) + for j := range cloned[i].Builds { + cloned[i].Builds[j].Artifacts = append([]dto.NginxModuleArtifact(nil), modules[i].Builds[j].Artifacts...) + } + } + return cloned +} + +func normalizeDynamicModuleParams(params string) (string, error) { + params = strings.TrimSpace(params) + params = strings.ReplaceAll(params, "--add-module=", "--add-dynamic-module=") + if !strings.Contains(params, "--add-dynamic-module=") && !strings.Contains(params, "=dynamic") { + return "", errors.New("module does not declare a dynamic configure option") + } + if _, err := parseDynamicModuleParams(params); err != nil { + return "", err + } + return params, nil +} + +func parseDynamicModuleParams(params string) ([]string, error) { + // shellwords silently stops at unquoted shell metacharacters instead of + // reporting them, so reject them on the raw input before parsing. + if strings.ContainsAny(params, "\x00\r\n;&|<>") { + return nil, errors.New("dynamic module parameters contain unsupported characters") + } + parser := shellwords.NewParser() + parser.ParseBacktick = false + parser.ParseEnv = false + args, err := parser.Parse(params) + if err != nil { + return nil, fmt.Errorf("parse dynamic module parameters: %w", err) + } + if len(args) == 0 { + return nil, errors.New("dynamic module parameters are empty") + } + for _, arg := range args { + if !strings.HasPrefix(arg, "--") { + return nil, fmt.Errorf("unsupported configure argument %q", arg) + } + if strings.ContainsAny(arg, "\x00\r\n;&|<>") { + return nil, fmt.Errorf("configure argument %q contains unsupported characters", arg) + } + } + return args, nil +} + +func validateNginxModulePackages(packages []string) error { + for _, item := range packages { + if !nginxModulePackagePattern.MatchString(item) { + return fmt.Errorf("invalid build package %q", item) + } + } + return nil +} + +func nginxModuleBuildHash(module dto.NginxModule, target dto.NginxModuleTarget, params string) (string, error) { + payload := struct { + Name string + Script string + Packages []string + Params string + TargetKey string + Provider string + }{module.Name, module.Script, module.Packages, params, target.Key, module.Provider} + content, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]), nil +} + +func (localNginxModuleProvider) Resolve(spec nginxModuleBuildSpec) (dto.NginxModuleBuild, error) { + params, err := normalizeDynamicModuleParams(spec.Module.Params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + configureArgs, err := parseDynamicModuleParams(params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + if err = validateNginxModulePackages(spec.Module.Packages); err != nil { + return dto.NginxModuleBuild{}, err + } + buildHash, err := nginxModuleBuildHash(spec.Module, spec.Target, params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + result := dto.NginxModuleBuild{ + Provider: nginxModuleProviderLocal, + Status: nginxModuleStatusPending, + Hash: buildHash, + Target: spec.Target, + } + modulesRoot := path.Join(spec.Install.GetPath(), "modules") + if !spec.Force { + if current := findCurrentNginxModuleBuild(spec.Module, spec.Target); current != nil && current.Status == nginxModuleStatusReady { + if validateNginxModuleArtifacts(spec.Install, current.Artifacts) == nil { + return *current, nil + } + } + } + outputRevision := buildHash + if spec.Force { + outputRevision = fmt.Sprintf("%s-r%d", buildHash, time.Now().UnixNano()) + } + modulePathName := nginxModulePathName(spec.Module.Name) + finalPath := path.Join(modulesRoot, spec.Target.Key, modulePathName, outputRevision) + buildComplete := false + defer func() { + if !buildComplete { + _ = os.RemoveAll(finalPath) + } + }() + + preScriptPath := path.Join(spec.BuildPath, "tmp", "module-pre.sh") + if err = os.WriteFile(preScriptPath, []byte("#!/bin/bash\nset -e\n"+spec.Module.Script+"\n"), constant.FilePerm); err != nil { + return result, err + } + defer os.Remove(preScriptPath) + configureArgsPath := path.Join(spec.BuildPath, "tmp", "module-config.args") + if err = os.WriteFile(configureArgsPath, []byte(strings.Join(configureArgs, "\n")+"\n"), constant.FilePerm); err != nil { + return result, err + } + defer os.Remove(configureArgsPath) + + shortHash := shortNginxModuleHash(buildHash) + taskSuffix := sanitizeModulePathPart(spec.Task.TaskID) + if len(taskSuffix) > 8 { + taskSuffix = taskSuffix[:8] + } + tempImage := fmt.Sprintf("1panel/openresty-module-builder:%s-%s-%s", modulePathName, shortHash, taskSuffix) + tempContainer := fmt.Sprintf("1panel-module-%s-%s-%s", modulePathName, shortHash, taskSuffix) + commandMgr := cmd.NewCommandMgr(cmd.WithTask(*spec.Task), cmd.WithTimeout(120*time.Minute)) + buildArgs := []string{ + "build", "--target", "module-output", + "-f", path.Join(spec.BuildPath, "Dockerfile.modules"), + "-t", tempImage, + "--build-arg", "PANEL_OPENRESTY_VERSION=" + spec.Install.Version, + "--build-arg", "RESTY_ADD_PACKAGE_BUILDDEPS=" + strings.Join(spec.Module.Packages, " "), + spec.BuildPath, + } + if err = commandMgr.Run("docker", buildArgs...); err != nil { + return result, err + } + cleanupMgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute)) + defer func() { + _ = cleanupMgr.Run("docker", "rm", "-f", tempContainer) + _ = cleanupMgr.Run("docker", "image", "rm", "-f", tempImage) + }() + if err = commandMgr.Run("docker", "create", "--name", tempContainer, tempImage, "/bin/true"); err != nil { + return result, err + } + + stagingPath := path.Join(spec.Install.GetPath(), "modules", ".staging", modulePathName+"-"+shortHash) + _ = os.RemoveAll(stagingPath) + if err = os.MkdirAll(stagingPath, constant.DirPerm); err != nil { + return result, err + } + defer os.RemoveAll(stagingPath) + if err = commandMgr.Run("docker", "cp", tempContainer+":/out/.", stagingPath); err != nil { + return result, err + } + if artifacts, artifactErr := collectNginxModuleArtifacts(stagingPath, stagingPath); artifactErr != nil || len(artifacts) == 0 { + if artifactErr != nil { + return result, artifactErr + } + return result, errors.New("dynamic module build produced no loadable .so files") + } + if err = os.MkdirAll(path.Dir(finalPath), constant.DirPerm); err != nil { + return result, err + } + _ = os.RemoveAll(finalPath) + if err = os.Rename(stagingPath, finalPath); err != nil { + return result, err + } + artifacts, err := collectNginxModuleArtifacts(finalPath, modulesRoot) + if err != nil { + return result, err + } + result.Status = nginxModuleStatusReady + result.Artifacts = artifacts + result.BuiltAt = time.Now() + manifest, _ := json.MarshalIndent(result, "", " ") + _ = os.WriteFile(path.Join(finalPath, "manifest.json"), manifest, constant.FilePerm) + buildComplete = true + return result, nil +} + +func collectNginxModuleArtifacts(root, relativeRoot string) ([]dto.NginxModuleArtifact, error) { + var artifacts []dto.NginxModuleArtifact + err := filepath.Walk(root, func(filePath string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || filepath.Ext(info.Name()) != ".so" || filepath.Base(filepath.Dir(filePath)) == "lib" { + return nil + } + file, err := os.Open(filePath) + if err != nil { + return err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + _ = file.Close() + if copyErr != nil { + return copyErr + } + relativePath, err := filepath.Rel(relativeRoot, filePath) + if err != nil { + return err + } + artifacts = append(artifacts, dto.NginxModuleArtifact{ + Name: info.Name(), Path: filepath.ToSlash(relativePath), Checksum: hex.EncodeToString(hash.Sum(nil)), + }) + return nil + }) + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) + return artifacts, err +} + +func resolveNginxModuleArtifactPath(install model.AppInstall, artifactPath string) (string, error) { + if artifactPath == "" || path.IsAbs(artifactPath) || path.Clean(artifactPath) != artifactPath || + strings.Contains(artifactPath, `\`) || strings.HasPrefix(artifactPath, "../") || + !nginxModuleArtifactPattern.MatchString(artifactPath) { + return "", fmt.Errorf("invalid module artifact path %q", artifactPath) + } + modulesRoot := filepath.Clean(path.Join(install.GetPath(), "modules")) + artifactFullPath := filepath.Clean(filepath.Join(modulesRoot, filepath.FromSlash(artifactPath))) + rel, err := filepath.Rel(modulesRoot, artifactFullPath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("module artifact path %q escapes the module directory", artifactPath) + } + return artifactFullPath, nil +} + +func validateNginxModuleArtifacts(install model.AppInstall, artifacts []dto.NginxModuleArtifact) error { + if len(artifacts) == 0 { + return errors.New("module build produced no loadable artifacts") + } + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + if _, ok := seen[artifact.Path]; ok { + return fmt.Errorf("duplicate module artifact path %q", artifact.Path) + } + seen[artifact.Path] = struct{}{} + if artifact.Name != path.Base(artifact.Path) { + return fmt.Errorf("module artifact name %q does not match path %q", artifact.Name, artifact.Path) + } + if !nginxModuleChecksumPattern.MatchString(artifact.Checksum) { + return fmt.Errorf("invalid checksum for module artifact %q", artifact.Path) + } + artifactFullPath, err := resolveNginxModuleArtifactPath(install, artifact.Path) + if err != nil { + return err + } + info, err := os.Lstat(artifactFullPath) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("module artifact %q is not a regular file", artifact.Path) + } + file, err := os.Open(artifactFullPath) + if err != nil { + return err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + actualChecksum := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actualChecksum, artifact.Checksum) { + return fmt.Errorf("checksum mismatch for module artifact %q", artifact.Path) + } + } + return nil +} + +func getNginxModuleProvider(name string) (nginxModuleArtifactProvider, error) { + switch name { + case "", nginxModuleProviderLocal: + return localNginxModuleProvider{}, nil + case "prebuilt": + return nil, errors.New("prebuilt module provider is not configured; use the local provider") + default: + return nil, fmt.Errorf("unknown module artifact provider %q", name) + } +} + +// nginxModuleBuildFailedError identifies the module a dynamic build failed +// for, so callers can decide whether a static fallback is allowed. +type nginxModuleBuildFailedError struct { + Module string + Err error +} + +func (e *nginxModuleBuildFailedError) Error() string { + return e.Err.Error() +} + +func (e *nginxModuleBuildFailedError) Unwrap() error { + return e.Err +} + +// nginxModuleNeedsDynamicBuild mirrors the dynamic-build filter of +// buildDynamicNginxModules. It normalizes a copy so prescan callers never +// mutate the stored entities. +func nginxModuleNeedsDynamicBuild(module dto.NginxModule, selectedNames map[string]struct{}) bool { + normalizeNginxModule(&module) + if module.Deleted || module.BuildMode == nginxModuleBuildStatic { + return false + } + if len(selectedNames) > 0 { + _, ok := selectedNames[module.Name] + return ok + } + return module.Enable +} + +func hasDynamicNginxModuleBuildTask(modules []dto.NginxModule, selected []string) bool { + selectedNames := make(map[string]struct{}, len(selected)) + for _, name := range selected { + selectedNames[name] = struct{}{} + } + for _, module := range modules { + if nginxModuleNeedsDynamicBuild(module, selectedNames) { + return true + } + } + return false +} + +// shouldFallbackNginxModuleToStatic reports whether a failed dynamic build may +// fall back to a static build. Only auto mode gets the fallback; an explicit +// dynamic choice keeps the failure semantics. +func shouldFallbackNginxModuleToStatic(module dto.NginxModule) bool { + normalizeNginxModule(&module) + return module.BuildMode == nginxModuleBuildAuto +} + +// fallbackNginxModuleToStatic flips the module identified by buildFailed to a +// static build after its dynamic build failed. It reports whether the fallback +// was performed. +func fallbackNginxModuleToStatic(modules []dto.NginxModule, buildFailed *nginxModuleBuildFailedError) bool { + for i := range modules { + if modules[i].Name != buildFailed.Module { + continue + } + if !shouldFallbackNginxModuleToStatic(modules[i]) { + return false + } + modules[i].BuildMode = nginxModuleBuildStatic + modules[i].LastError = fmt.Sprintf("dynamic build failed: %v; module switched to static build", buildFailed.Err) + return true + } + return false +} + +func buildDynamicNginxModules(install model.AppInstall, modules []dto.NginxModule, selected []string, force bool, parentTask *task.Task) ([]dto.NginxModule, error) { + // Skip target resolution entirely when nothing needs a dynamic build, so + // installs without dynamic modules do not require Dockerfile.modules. + if !hasDynamicNginxModuleBuildTask(modules, selected) { + return modules, nil + } + target, targetWarning, err := resolveNginxModuleTarget(install) + if err != nil { + if !errors.Is(err, errNginxModuleBuilderMissing) { + return modules, err + } + // The new app version ships no dynamic builder: degrade to inactive + // modules instead of failing the surrounding install or upgrade. + degraded := fmt.Sprintf("dynamic module builder missing, enabled dynamic modules stay inactive: %v", err) + if parentTask != nil { + parentTask.Logf("WARNING: %s", degraded) + } else { + global.LOG.Warn(degraded) + } + for i := range modules { + module := &modules[i] + normalizeNginxModule(module) + if module.Deleted || !module.Enable || module.BuildMode == nginxModuleBuildStatic { + continue + } + module.LastError = "dynamic module builder (Dockerfile.modules) not found for target version; module kept inactive" + } + return modules, nil + } + if targetWarning != "" { + parentTask.Logf("WARNING: %s", targetWarning) + } + originalModules := cloneNginxModules(modules) + selectedNames := make(map[string]struct{}, len(selected)) + for _, name := range selected { + selectedNames[name] = struct{}{} + } + buildPath := path.Join(install.GetPath(), "build") + for i := range modules { + module := &modules[i] + normalizeNginxModule(module) + if !nginxModuleNeedsDynamicBuild(*module, selectedNames) { + continue + } + provider, providerErr := getNginxModuleProvider(module.Provider) + if providerErr != nil { + return modules, &nginxModuleBuildFailedError{Module: module.Name, Err: providerErr} + } + previousBuild := findCurrentNginxModuleBuild(*module, target) + build, buildErr := provider.Resolve(nginxModuleBuildSpec{ + Install: install, Module: *module, Target: target, BuildPath: buildPath, Force: force, Task: parentTask, + }) + if buildErr != nil { + build.Provider = provider.Name() + build.Status = nginxModuleStatusFailed + build.Target = target + build.Error = buildErr.Error() + build.BuiltAt = time.Now() + failedModules := recordNginxModuleBuildFailure(originalModules, module.Name, build, previousBuild, false) + removeNginxModuleOutputsNotReferenced(install, modules, failedModules) + _ = saveNginxModules(install, failedModules) + return failedModules, &nginxModuleBuildFailedError{Module: module.Name, Err: fmt.Errorf("build dynamic module %s: %w", module.Name, buildErr)} + } + module.DynamicSupport = nginxModuleSupportSupported + err = validateNginxModuleArtifacts(install, build.Artifacts) + if err != nil { + removeNginxModuleBuildOutput(install, build) + build.Status = nginxModuleStatusFailed + build.Error = err.Error() + build.Artifacts = nil + build.BuiltAt = time.Now() + failedModules := recordNginxModuleBuildFailure(originalModules, module.Name, build, previousBuild, true) + removeNginxModuleOutputsNotReferenced(install, modules, failedModules) + _ = saveNginxModules(install, failedModules) + return failedModules, &nginxModuleBuildFailedError{Module: module.Name, Err: fmt.Errorf("validate dynamic module %s: %w", module.Name, err)} + } + module.LastError = "" + upsertNginxModuleBuild(module, build) + } + return modules, nil +} + +func recordNginxModuleBuildFailure(originalModules []dto.NginxModule, moduleName string, build dto.NginxModuleBuild, previousBuild *dto.NginxModuleBuild, dynamicCompileSucceeded bool) []dto.NginxModule { + failedModules := cloneNginxModules(originalModules) + for i := range failedModules { + if failedModules[i].Name != moduleName { + continue + } + failedModules[i].LastError = build.Error + if dynamicCompileSucceeded { + failedModules[i].DynamicSupport = nginxModuleSupportSupported + } + if previousBuild == nil || previousBuild.Status != nginxModuleStatusReady { + upsertNginxModuleBuild(&failedModules[i], build) + } + break + } + return failedModules +} + +func removeNginxModuleBuildOutput(install model.AppInstall, build dto.NginxModuleBuild) { + for outputDir := range nginxModuleOutputDirectories(install, []dto.NginxModule{{Builds: []dto.NginxModuleBuild{build}}}) { + _ = os.RemoveAll(outputDir) + } +} + +func nginxModuleOutputDirectories(install model.AppInstall, modules []dto.NginxModule) map[string]struct{} { + result := make(map[string]struct{}) + for _, module := range modules { + for _, build := range module.Builds { + for _, artifact := range build.Artifacts { + artifactFullPath, err := resolveNginxModuleArtifactPath(install, artifact.Path) + if err != nil { + continue + } + outputDir := filepath.Dir(artifactFullPath) + modulesRoot := filepath.Clean(path.Join(install.GetPath(), "modules")) + if outputDir != modulesRoot { + result[outputDir] = struct{}{} + } + } + } + } + return result +} + +func removeNginxModuleOutputsNotReferenced(install model.AppInstall, fromModules, referencedModules []dto.NginxModule) { + referenced := nginxModuleOutputDirectories(install, referencedModules) + for outputDir := range nginxModuleOutputDirectories(install, fromModules) { + if _, ok := referenced[outputDir]; !ok { + _ = os.RemoveAll(outputDir) + } + } +} + +func validateNginxModuleLoadConfig(install model.AppInstall, target dto.NginxModuleTarget, validationName, loadDirectives string) error { + if target.Image == "" { + return errors.New("target OpenResty image is not resolved") + } + var config strings.Builder + config.WriteString(loadDirectives) + config.WriteString("events {}\nhttp {}\n") + configSum := sha256.Sum256([]byte(config.String())) + testConfig := path.Join(install.GetPath(), "modules", ".test-"+nginxModulePathName(validationName)+"-"+hex.EncodeToString(configSum[:6])+".conf") + if err := os.WriteFile(testConfig, []byte(config.String()), constant.FilePerm); err != nil { + return err + } + defer os.Remove(testConfig) + modulesRoot := path.Join(install.GetPath(), "modules") + args := []string{ + "run", "--rm", "--network", "none", + "-v", modulesRoot + ":" + nginxModuleContainerRoot + ":ro", + "-v", testConfig + ":/tmp/1panel-module-test.conf:ro", + "--entrypoint", "/usr/local/openresty/nginx/sbin/nginx", + target.Image, "-t", "-c", "/tmp/1panel-module-test.conf", + } + return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run("docker", args...) +} + +func commitNginxModuleBuilds(install model.AppInstall, previousModules, modules []dto.NginxModule, reload bool) error { + if err := saveNginxModules(install, modules); err != nil { + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return err + } + if err := reconcileDynamicNginxModuleConfig(install, modules, reload); err != nil { + rollbackModules := recordNginxModuleActivationFailure(previousModules, modules, err) + _ = saveNginxModules(install, rollbackModules) + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return err + } + removeNginxModuleOutputsNotReferenced(install, previousModules, modules) + return nil +} + +func recordNginxModuleActivationFailure(previousModules, candidateModules []dto.NginxModule, activationErr error) []dto.NginxModule { + rollbackModules := cloneNginxModules(previousModules) + candidates := make(map[string]dto.NginxModule, len(candidateModules)) + for _, module := range candidateModules { + candidates[module.Name] = module + } + for i := range rollbackModules { + candidate, ok := candidates[rollbackModules[i].Name] + if !ok || candidate.Deleted || !candidate.Enable || candidate.BuildMode == nginxModuleBuildStatic { + continue + } + rollbackModules[i].LastError = activationErr.Error() + rollbackModules[i].DynamicSupport = candidate.DynamicSupport + } + return rollbackModules +} + +func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool { + for _, module := range modules { + normalizeNginxModule(&module) + if !module.Deleted && module.Enable && module.BuildMode == nginxModuleBuildStatic { + return true + } + } + return false +} + +func staticNginxBuildRequired(install model.AppInstall, modules []dto.NginxModule) bool { + if hasEnabledStaticNginxModules(modules) { + return true + } + envs, err := gotenv.Read(install.GetEnvPath()) + if err != nil { + return false + } + return strings.TrimSpace(envs["RESTY_CONFIG_OPTIONS_MORE"]) != "" +} + +func configureStaticNginxModules(install model.AppInstall, modules []dto.NginxModule, mirror string) error { + buildPath := path.Join(install.GetPath(), "build") + var params, packages []string + preScriptPath := path.Join(buildPath, "tmp", "pre.sh") + preScript, err := os.OpenFile(preScriptPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.FilePerm) + if err != nil { + return err + } + for _, module := range modules { + normalizeNginxModule(&module) + if module.Deleted || !module.Enable || module.BuildMode != nginxModuleBuildStatic { + continue + } + if _, err = preScript.WriteString(module.Script + "\n"); err != nil { + _ = preScript.Close() + return err + } + params = append(params, module.Params) + packages = append(packages, module.Packages...) + } + if err = preScript.Close(); err != nil { + return err + } + envs, err := gotenv.Read(install.GetEnvPath()) + if err != nil { + return err + } + if mirror != "" { + envs["CONTAINER_PACKAGE_URL"] = mirror + } + envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(compactStrings(params), " ") + envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(compactStrings(packages), " ") + return gotenv.Write(envs, install.GetEnvPath()) +} + +type nginxModuleConfigSnapshot map[string][]byte + +func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.NginxModule, reload bool) error { + target, targetWarning, err := resolveNginxModuleTarget(install) + if err != nil { + if !errors.Is(err, errNginxModuleBuilderMissing) { + return err + } + // Without the builder every dynamic module stays inactive: reconcile + // towards an empty desired state instead of failing the caller. + global.LOG.Warn(err.Error()) + } else if targetWarning != "" { + global.LOG.Warn(targetWarning) + } + configDir := path.Join(install.GetPath(), "conf", "modules-enabled") + if err = os.MkdirAll(configDir, constant.DirPerm); err != nil { + return err + } + snapshot, err := snapshotManagedNginxModuleConfigs(configDir) + if err != nil { + return err + } + desired := make(map[string][]byte) + var combinedLoadDirectives strings.Builder + sortedModules := append([]dto.NginxModule(nil), modules...) + sort.SliceStable(sortedModules, func(i, j int) bool { + if sortedModules[i].LoadOrder == sortedModules[j].LoadOrder { + return sortedModules[i].Name < sortedModules[j].Name + } + return sortedModules[i].LoadOrder < sortedModules[j].LoadOrder + }) + for _, module := range sortedModules { + normalizeNginxModule(&module) + if module.Deleted || !module.Enable || module.BuildMode == nginxModuleBuildStatic { + continue + } + build := findCurrentNginxModuleBuild(module, target) + if build == nil || build.Status != nginxModuleStatusReady { + build = findLatestNginxModuleBuild(module, target) + } + if build == nil || build.Status != nginxModuleStatusReady { + continue + } + if err = validateNginxModuleArtifacts(install, build.Artifacts); err != nil { + return fmt.Errorf("validate artifacts for dynamic module %s: %w", module.Name, err) + } + var content strings.Builder + content.WriteString("# Managed by 1Panel. Manual changes will be overwritten.\n") + for _, artifact := range build.Artifacts { + content.WriteString("load_module ") + content.WriteString(path.Join(nginxModuleContainerRoot, artifact.Path)) + content.WriteString(";\n") + combinedLoadDirectives.WriteString("load_module ") + combinedLoadDirectives.WriteString(path.Join(nginxModuleContainerRoot, artifact.Path)) + combinedLoadDirectives.WriteString(";\n") + } + fileName := fmt.Sprintf("%s%04d-%s.conf", nginxModuleConfigPrefix, module.LoadOrder, nginxModulePathName(module.Name)) + desired[fileName] = []byte(content.String()) + } + if combinedLoadDirectives.Len() > 0 { + if err = validateNginxModuleLoadConfig(install, target, "combined", combinedLoadDirectives.String()); err != nil { + return fmt.Errorf("validate combined dynamic module configuration: %w", err) + } + } + if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil { + _ = restoreManagedNginxModuleConfigs(configDir, snapshot) + return err + } + if !reload { + return nil + } + status, statusErr := checkContainerStatus(install.ContainerName) + if statusErr != nil || status != "running" { + return nil + } + if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { + _ = restoreManagedNginxModuleConfigs(configDir, snapshot) + return err + } + if err = opNginx(install.ContainerName, constant.NginxReload); err != nil { + _ = restoreManagedNginxModuleConfigs(configDir, snapshot) + return err + } + return nil +} + +func snapshotManagedNginxModuleConfigs(configDir string) (nginxModuleConfigSnapshot, error) { + snapshot := make(nginxModuleConfigSnapshot) + entries, err := os.ReadDir(configDir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxModuleConfigPrefix) { + continue + } + content, readErr := os.ReadFile(path.Join(configDir, entry.Name())) + if readErr != nil { + return nil, readErr + } + snapshot[entry.Name()] = content + } + return snapshot, nil +} + +func applyManagedNginxModuleConfigs(configDir string, desired map[string][]byte) error { + entries, err := os.ReadDir(configDir) + if err != nil { + return err + } + for fileName, content := range desired { + tmpPath := path.Join(configDir, "."+fileName+".tmp") + if err = os.WriteFile(tmpPath, content, constant.FilePerm); err != nil { + return err + } + if err = os.Rename(tmpPath, path.Join(configDir, fileName)); err != nil { + return err + } + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxModuleConfigPrefix) { + continue + } + if _, ok := desired[entry.Name()]; !ok { + if err = os.Remove(path.Join(configDir, entry.Name())); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +func restoreManagedNginxModuleConfigs(configDir string, snapshot nginxModuleConfigSnapshot) error { + return applyManagedNginxModuleConfigs(configDir, snapshot) +} + +func executeNginxModuleBuild(install model.AppInstall, reqModules []string, force bool, mirror string, parentTask *task.Task, reload bool) error { + modules, err := loadNginxModules(install) + if err != nil { + return err + } + staticBuild := staticNginxBuildRequired(install, modules) + if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) { + // An explicit build request must fail loudly when the installed version + // ships no dynamic builder; the automatic flows degrade instead. + builderPath := path.Join(install.GetPath(), "build", "Dockerfile.modules") + if _, statErr := os.Stat(builderPath); statErr != nil { + return fmt.Errorf("%w: %v", errNginxModuleBuilderMissing, statErr) + } + } + if staticBuild { + return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask) + } + previousModules := cloneNginxModules(modules) + modules, err = buildDynamicNginxModules(install, modules, reqModules, force, parentTask) + if err != nil { + var buildFailed *nginxModuleBuildFailedError + if !errors.As(err, &buildFailed) || !fallbackNginxModuleToStatic(modules, buildFailed) { + return err + } + // Auto mode promises a working module, not a build strategy: the failed + // module was flipped to static, now run the full static chain. + parentTask.Logf("WARNING: dynamic build of module %s failed: %v; falling back to static build", buildFailed.Module, buildFailed.Err) + if staticErr := executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask); staticErr != nil { + for i := range modules { + if modules[i].Name == buildFailed.Module { + modules[i].BuildMode = nginxModuleBuildAuto + modules[i].LastError = fmt.Sprintf("dynamic build failed: %v; static fallback also failed: %v", buildFailed.Err, staticErr) + break + } + } + _ = saveNginxModules(install, modules) + return fmt.Errorf("%v; static fallback build also failed: %w", err, staticErr) + } + return nil + } + return commitNginxModuleBuilds(install, previousModules, modules, reload) +} + +func executeStaticNginxModuleBuild(install model.AppInstall, modules []dto.NginxModule, mirror string, force bool, parentTask *task.Task) error { + if err := configureStaticNginxModules(install, modules, mirror); err != nil { + return err + } + commandMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute)) + if err := commandMgr.Run("docker", "compose", "-f", install.GetComposePath(), "build"); err != nil { + return err + } + previousModules := cloneNginxModules(modules) + // A rebuilt runtime changes the target ABI, so every enabled dynamic module + // must be rebuilt even when the user selected only one module. + modules, err := buildDynamicNginxModules(install, modules, nil, force, parentTask) + if err != nil { + return err + } + if err = commitNginxModuleBuilds(install, previousModules, modules, false); err != nil { + return err + } + _, err = compose.DownAndUp(install.GetComposePath()) + return err +} + +func removeNginxModuleArtifacts(install model.AppInstall, module dto.NginxModule) error { + for _, build := range module.Builds { + moduleDir := path.Join(install.GetPath(), "modules", build.Target.Key, nginxModulePathName(module.Name)) + if err := files.NewFileOp().DeleteDir(moduleDir); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} diff --git a/agent/app/service/nginx_module_test.go b/agent/app/service/nginx_module_test.go new file mode 100644 index 000000000000..a3f49b557628 --- /dev/null +++ b/agent/app/service/nginx_module_test.go @@ -0,0 +1,303 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "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" +) + +func TestNormalizeNginxModulePreservesLegacyStaticMode(t *testing.T) { + module := dto.NginxModule{ + Name: "legacy", + Packages: []string{"git", "", "git", " curl "}, + } + + normalizeNginxModule(&module) + + if module.BuildMode != nginxModuleBuildStatic { + t.Fatalf("expected legacy module to remain static, got %s", module.BuildMode) + } + if module.Provider != nginxModuleProviderLocal { + t.Fatalf("expected local provider, got %s", module.Provider) + } + if len(module.Packages) != 2 || module.Packages[0] != "git" || module.Packages[1] != "curl" { + t.Fatalf("unexpected normalized packages: %#v", module.Packages) + } +} + +func TestNormalizeDynamicModuleParams(t *testing.T) { + params, err := normalizeDynamicModuleParams("--with-http_dav_module --add-module=/tmp/nginx-dav-ext-module") + if err != nil { + t.Fatal(err) + } + expected := "--with-http_dav_module --add-dynamic-module=/tmp/nginx-dav-ext-module" + if params != expected { + t.Fatalf("expected %q, got %q", expected, params) + } + + if _, err = normalizeDynamicModuleParams("--add-module=/tmp/module;touch /tmp/unsafe"); err == nil { + t.Fatal("expected shell metacharacters to be rejected") + } +} + +func TestFindCurrentAndLatestNginxModuleBuild(t *testing.T) { + target := dto.NginxModuleTarget{Key: "target"} + module := dto.NginxModule{ + Name: "example", + Params: "--add-module=/tmp/example", + BuildMode: nginxModuleBuildDynamic, + Provider: nginxModuleProviderLocal, + } + params, err := normalizeDynamicModuleParams(module.Params) + if err != nil { + t.Fatal(err) + } + currentHash, err := nginxModuleBuildHash(module, target, params) + if err != nil { + t.Fatal(err) + } + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + module.Builds = []dto.NginxModuleBuild{ + {Hash: "old", Status: nginxModuleStatusReady, Target: target, BuiltAt: oldTime}, + {Hash: currentHash, Status: nginxModuleStatusReady, Target: target, BuiltAt: newTime}, + } + + if build := findCurrentNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatalf("current build was not selected: %#v", build) + } + if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatalf("latest build was not selected: %#v", build) + } + + module.Params = "--add-module=/tmp/example-v2" + if build := findCurrentNginxModuleBuild(module, target); build != nil { + t.Fatalf("changed module input should be stale, got %#v", build) + } + if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatal("the previous ready build should remain available until replacement") + } +} + +func TestNginxModulePathNameAvoidsSanitizedNameCollisions(t *testing.T) { + first := nginxModulePathName("example/module") + second := nginxModulePathName("example-module") + if first == second { + t.Fatalf("module path names collided: %s", first) + } + if len(nginxModulePathName(string(make([]byte, 256)))) > 57 { + t.Fatal("module path name should remain safe for Docker resource names") + } +} + +func TestRecordNginxModuleBuildFailureKeepsPreviousReadyBuild(t *testing.T) { + target := dto.NginxModuleTarget{Key: "target"} + ready := dto.NginxModuleBuild{ + Hash: "ready", Status: nginxModuleStatusReady, Target: target, BuiltAt: time.Now().Add(-time.Hour), + } + original := []dto.NginxModule{{ + Name: "example", BuildMode: nginxModuleBuildDynamic, DynamicSupport: nginxModuleSupportUnknown, + Builds: []dto.NginxModuleBuild{ready}, + }} + failed := dto.NginxModuleBuild{ + Hash: "candidate", Status: nginxModuleStatusFailed, Target: target, Error: "load failed", BuiltAt: time.Now(), + } + + result := recordNginxModuleBuildFailure(original, "example", failed, &ready, true) + + if len(result[0].Builds) != 1 || result[0].Builds[0].Hash != "ready" { + t.Fatalf("previous ready build was replaced: %#v", result[0].Builds) + } + if result[0].LastError != failed.Error || result[0].DynamicSupport != nginxModuleSupportSupported { + t.Fatalf("failure metadata was not retained: %#v", result[0]) + } + result[0].Builds[0].Hash = "mutated" + if original[0].Builds[0].Hash != "ready" { + t.Fatal("module clone shares build state with the original") + } +} + +func TestHasDynamicNginxModuleBuildTask(t *testing.T) { + dynamicEnabled := dto.NginxModule{Name: "brotli", Enable: true, BuildMode: nginxModuleBuildDynamic} + staticEnabled := dto.NginxModule{Name: "pagespeed", Enable: true, BuildMode: nginxModuleBuildStatic} + deletedDynamic := dto.NginxModule{Name: "geoip", Enable: true, BuildMode: nginxModuleBuildDynamic, Deleted: true} + disabledDynamic := dto.NginxModule{Name: "waf", Enable: false, BuildMode: nginxModuleBuildDynamic} + + if hasDynamicNginxModuleBuildTask(nil, nil) { + t.Fatal("empty module list should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{staticEnabled}, nil) { + t.Fatal("static-only modules should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{deletedDynamic}, nil) { + t.Fatal("deleted modules should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled}, []string{"other"}) { + t.Fatal("enabled module outside the selection should not require a dynamic build") + } + if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{disabledDynamic}, []string{"waf"}) { + t.Fatal("selected module should require a dynamic build even when disabled") + } + if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled, staticEnabled}, nil) { + t.Fatal("enabled dynamic module should require a dynamic build") + } + + legacy := dto.NginxModule{Name: "legacy", Enable: true} + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{legacy}, nil) { + t.Fatal("legacy module without a build mode stays static and should not require a dynamic build") + } + if legacy.BuildMode != "" { + t.Fatalf("prescan must normalize a copy, got mutated BuildMode %q", legacy.BuildMode) + } +} + +func TestResolveNginxModuleTargetWithoutBuilder(t *testing.T) { + oldDir := global.Dir.AppInstallDir + global.Dir.AppInstallDir = t.TempDir() + t.Cleanup(func() { global.Dir.AppInstallDir = oldDir }) + install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"} + install.App.Key = constant.AppOpenresty + + _, _, err := resolveNginxModuleTarget(install) + if !errors.Is(err, errNginxModuleBuilderMissing) { + t.Fatalf("expected builder-missing sentinel, got %v", err) + } + if !strings.Contains(err.Error(), "dynamic module builder not found") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestMergeOpenrestyModuleVolumes(t *testing.T) { + newService := map[string]interface{}{} + oldService := map[string]interface{}{ + "volumes": []interface{}{ + "./conf:/etc/nginx/conf.d:ro", + "./modules:/usr/local/openresty/nginx/modules/1panel:ro", + "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro", + 12345, + }, + } + + mergeOpenrestyModuleVolumes(newService, oldService) + + merged, ok := newService["volumes"].([]interface{}) + if !ok || len(merged) != 2 { + t.Fatalf("expected two module mounts to be merged, got %#v", newService["volumes"]) + } + for _, volume := range merged { + if volume == "./conf:/etc/nginx/conf.d:ro" { + t.Fatal("unrelated mount should not be merged") + } + } +} + +func TestMergeOpenrestyModuleVolumesKeepsExisting(t *testing.T) { + existing := "./modules:/usr/local/openresty/nginx/modules/1panel:ro" + newService := map[string]interface{}{ + "volumes": []interface{}{existing, map[string]interface{}{"type": "bind"}}, + } + oldService := map[string]interface{}{ + "volumes": []interface{}{ + "./modules:/usr/local/openresty/nginx/modules/1panel:ro", + "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro", + }, + } + + mergeOpenrestyModuleVolumes(newService, oldService) + + merged, ok := newService["volumes"].([]interface{}) + if !ok || len(merged) != 3 { + t.Fatalf("expected only the missing mount to be appended, got %#v", newService["volumes"]) + } + if merged[0] != existing { + t.Fatalf("existing mounts must keep their order, got %#v", merged) + } + if merged[2] != "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro" { + t.Fatalf("missing module mount was not appended, got %#v", merged) + } +} + +func TestNginxModuleBuildFailedError(t *testing.T) { + inner := fmt.Errorf("build dynamic module %s: %w", "brotli", errors.New("compiler exited")) + err := error(&nginxModuleBuildFailedError{Module: "brotli", Err: inner}) + if err.Error() != inner.Error() { + t.Fatalf("error text must stay identical, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "build dynamic module brotli: compiler exited") { + t.Fatalf("unexpected error text: %q", err.Error()) + } + var buildFailed *nginxModuleBuildFailedError + if !errors.As(err, &buildFailed) || buildFailed.Module != "brotli" { + t.Fatalf("errors.As should locate the failed module, got %#v", buildFailed) + } + if errors.As(errors.New("compiler exited"), &buildFailed) { + t.Fatal("plain errors must not carry a failed module") + } + + sentinel := error(&nginxModuleBuildFailedError{ + Module: "waf", + Err: fmt.Errorf("%w: %v", errNginxModuleBuilderMissing, errors.New("stat failed")), + }) + if !errors.Is(sentinel, errNginxModuleBuilderMissing) { + t.Fatal("Unwrap should keep the wrapped sentinel reachable") + } +} + +func TestShouldFallbackNginxModuleToStatic(t *testing.T) { + if !shouldFallbackNginxModuleToStatic(dto.NginxModule{Name: "auto", BuildMode: nginxModuleBuildAuto}) { + t.Fatal("auto module should fall back to static") + } + if shouldFallbackNginxModuleToStatic(dto.NginxModule{Name: "dyn", BuildMode: nginxModuleBuildDynamic}) { + t.Fatal("explicit dynamic module should keep the failure semantics") + } + if shouldFallbackNginxModuleToStatic(dto.NginxModule{Name: "static", BuildMode: nginxModuleBuildStatic}) { + t.Fatal("static module has nothing to fall back to") + } + if shouldFallbackNginxModuleToStatic(dto.NginxModule{Name: "legacy"}) { + t.Fatal("legacy module without a build mode normalizes to static and must not fall back") + } +} + +func TestFallbackNginxModuleToStatic(t *testing.T) { + buildErr := func(module string) *nginxModuleBuildFailedError { + return &nginxModuleBuildFailedError{ + Module: module, + Err: fmt.Errorf("build dynamic module %s: %w", module, errors.New("compiler exited")), + } + } + + modules := []dto.NginxModule{ + {Name: "auto-mod", Enable: true, BuildMode: nginxModuleBuildAuto}, + {Name: "dyn-mod", Enable: true, BuildMode: nginxModuleBuildDynamic}, + } + if !fallbackNginxModuleToStatic(modules, buildErr("auto-mod")) { + t.Fatal("auto module should be flipped to static") + } + if modules[0].BuildMode != nginxModuleBuildStatic { + t.Fatalf("expected static build mode, got %s", modules[0].BuildMode) + } + if !strings.Contains(modules[0].LastError, "compiler exited") || + !strings.Contains(modules[0].LastError, "switched to static build") { + t.Fatalf("LastError should record the dynamic failure and the switch, got %q", modules[0].LastError) + } + if modules[1].BuildMode != nginxModuleBuildDynamic || modules[1].LastError != "" { + t.Fatalf("unrelated module must stay untouched, got %#v", modules[1]) + } + + if fallbackNginxModuleToStatic(modules, buildErr("dyn-mod")) { + t.Fatal("explicit dynamic module must not fall back") + } + if modules[1].BuildMode != nginxModuleBuildDynamic { + t.Fatal("explicit dynamic module build mode must stay dynamic") + } + if fallbackNginxModuleToStatic(modules, buildErr("missing")) { + t.Fatal("unknown module must not fall back") + } +} diff --git a/scripts/openresty-modules/README.md b/scripts/openresty-modules/README.md new file mode 100644 index 000000000000..50c3cf9da495 --- /dev/null +++ b/scripts/openresty-modules/README.md @@ -0,0 +1,168 @@ +# OpenResty Dynamic Module Linux Tests + +These scripts test the local dynamic-module build path and collect diagnostics +from an installed 1Panel OpenResty instance. Run them on a disposable Linux +host with Docker access before testing on a production installation. + +## Requirements + +- Bash 4.3 or newer +- Docker Engine with the Compose v2 plugin +- `jq`, `python3`, `file`, `binutils`, `tar`, and GNU coreutils +- Internet access for runtime images and Ubuntu build packages +- Go, only when `--source-checks` is used + +On Debian or Ubuntu: + +```bash +sudo apt-get update +sudo apt-get install -y jq python3 file binutils tar +``` + +Make the scripts executable: + +```bash +chmod +x scripts/openresty-modules/*.sh +``` + +## Builder Test + +Start with one version and one small module: + +```bash +./scripts/openresty-modules/test-builder.sh \ + --appstore ../appstore \ + --versions 1.31.1.1-0-noble \ + --modules ngx_brotli \ + --source-checks +``` + +Test every catalog module against all refactored OpenResty versions: + +```bash +./scripts/openresty-modules/test-builder.sh --appstore ../appstore +``` + +Bypass Docker's module build cache when reproducing a compiler problem: + +```bash +./scripts/openresty-modules/test-builder.sh \ + --appstore ../appstore \ + --versions 1.31.1.1-0-noble \ + --modules geoip2 \ + --no-cache \ + --keep-context \ + --keep-docker +``` + +The builder test performs these phases for every selected version: + +1. Validate appstore JSON, shell scripts, Compose mounts, and Nginx include. +2. Pull and identify the exact target runtime image. +3. Convert catalog options to dynamic configure options. +4. Build every module with `Dockerfile.modules` and copy `/out` locally. +5. Record SHA-256, ELF metadata, compiler output, and runtime dependencies. +6. Validate individual modules for debugging. Individual failures are warnings + by default because modules may depend on an earlier module. +7. Validate all modules together in catalog `loadOrder`. +8. Start an isolated OpenResty master, add module configs, and hot reload. +9. Inject a missing module, prove `nginx -t` rejects it, restore the config, + and prove the running process remains healthy. + +Use `--strict-individual` when every selected module is expected to load alone. + +Results are written to: + +```text +openresty-module-test-results// +``` + +Important files: + +- `summary.tsv`: result per OpenResty version +- `work//logs/build-*.log`: complete BuildKit output +- `work//logs/load-combined.log`: authoritative ABI/load-order test +- `work//artifacts.tsv`: module paths, checksums, and sizes +- `work//image-inspect.json`: exact target image identity +- `work//runtime/`: reload and rollback test logs +- `.tar.gz`: automatically created when an unexpected failure occurs + +## Installed Instance Diagnostics + +Find the OpenResty installation directory first. A common path is similar to: + +```text +/opt/1panel/apps/openresty/openresty +``` + +Run the diagnostic collector: + +```bash +./scripts/openresty-modules/diagnose-install.sh \ + /opt/1panel/apps/openresty/openresty +``` + +Override container discovery when needed: + +```bash +./scripts/openresty-modules/diagnose-install.sh \ + /opt/1panel/apps/openresty/openresty \ + --container 1Panel-openresty +``` + +The collector checks: + +- `module.json` artifact paths and SHA-256 checksums +- managed `load_module` files and host/container path mapping +- read-only Compose mounts +- current container image ID versus enabled module target image IDs +- container state, Nginx build options, `nginx -t`, loaded module directives, + module checksums inside the container, `ldd`, and recent logs + +The default report does not retain full `nginx -T` output. Use +`--full-config` only on a test host because the resulting archive may contain +credentials or private site configuration. + +Module scripts are redacted from the copied state files by default. Container +logs and error strings can still contain site names, URLs, or command output; +review an archive before sharing it outside your team. + +## Final Manual Matrix + +Run this matrix through the 1Panel UI on a disposable installation. Collect a +diagnostic archive after each important transition. + +1. Install the oldest selected OpenResty version with every module disabled. +2. Switch one module to `auto`, enable it, and build it locally. +3. Confirm `buildStatus=ready`, `compatibility=compatible`, and `nginx -t`. +4. Force rebuild it. Confirm the artifact path changes and the old config is + replaced only after the new artifact passes validation. +5. Enable all catalog modules and verify catalog load order with the builder + test and the installed-instance collector. +6. On the test host, make one module script return a failure. Confirm the old + managed config and old ready artifact remain active. +7. Restore the module definition and rebuild successfully. +8. Upgrade OpenResty. Confirm every enabled dynamic module has a ready build + whose target image ID matches the new running container. +9. Restart the container and host. Run the diagnostic collector again to prove + the persisted mounts and configs remain valid. +10. Switch a module to `static`, rebuild, then switch it back to `dynamic` and + verify that all enabled dynamic modules are regenerated for the new image. + +## Failure Triage + +| Symptom | First evidence to inspect | +| --- | --- | +| Docker build fails | `logs/build-.log`, `inputs//` | +| `.so` missing | build log and the `module-output` stage `/out` checks | +| Individual load fails, combined passes | module dependency and `loadOrder` | +| Combined load fails | ABI mismatch, duplicate module, missing shared library | +| `ldd` shows `not found` | bundled `lib/`, RPATH, or future runtime packages | +| Checksum mismatch | interrupted copy, manual modification, stale state file | +| Target image mismatch | module was not rebuilt after image upgrade/rebuild | +| Builder passes, installed `nginx -t` fails | Compose mounts or managed config | +| Reload fails but old process runs | inspect rollback logs and old config snapshot | + +Do not edit generated module state or managed config files while a 1Panel app +task is running. Preserve the result directory and archive before retrying a +failed build. diff --git a/scripts/openresty-modules/diagnose-install.sh b/scripts/openresty-modules/diagnose-install.sh new file mode 100644 index 000000000000..1ea8532ba382 --- /dev/null +++ b/scripts/openresty-modules/diagnose-install.sh @@ -0,0 +1,475 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +INSTALL_DIR="" +OUTPUT_DIR="${OUTPUT_DIR:-${PWD}/openresty-module-diagnostics/${RUN_ID}}" +CONTAINER="" +FULL_CONFIG=0 +CREATE_ARCHIVE=1 +NGINX_TEST=1 +UNEXPECTED_FAILURE="" +declare -a FAILED_CHECKS=() + +usage() { + cat <<'EOF' +Usage: diagnose-install.sh INSTALL_DIR [options] + +Collect a mostly read-only diagnostic report for an installed 1Panel OpenResty. +The only container command with behavior is `nginx -t`; no reload is performed. + +Options: + --output PATH Result directory + --container NAME Override the container discovered from Docker Compose + --full-config Retain full `nginx -T` output (may contain sensitive data) + --no-nginx-test Do not execute nginx -t/-T in the running container + --no-archive Do not create a .tar.gz report + -h, --help Show this help +EOF +} + +log() { + printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "${OUTPUT_DIR}/run.log" +} + +mark_failed() { + FAILED_CHECKS+=("$1") + log "CHECK FAILED: $1" +} + +mark_passed() { + log "CHECK PASSED: $1" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Required command not found: %s\n' "$1" >&2 + exit 2 + fi +} + +on_error() { + local status="$1" line="$2" command="$3" + UNEXPECTED_FAILURE="line ${line}: ${command} (exit ${status})" + return "${status}" +} + +finalize() { + local status=$? + set +e + if [[ -n "${UNEXPECTED_FAILURE}" ]]; then + printf '%s\n' "${UNEXPECTED_FAILURE}" >"${OUTPUT_DIR}/unexpected-failure.txt" + fi + { + printf 'install_dir=%s\n' "${INSTALL_DIR}" + printf 'container=%s\n' "${CONTAINER}" + printf 'failed_checks=%s\n' "${#FAILED_CHECKS[@]}" + local check + for check in "${FAILED_CHECKS[@]:-}"; do + [[ -n "${check}" ]] && printf 'failure=%s\n' "${check}" + done + } >"${OUTPUT_DIR}/summary.txt" + + if [[ "${CREATE_ARCHIVE}" -eq 1 ]]; then + local archive="${OUTPUT_DIR%/}.tar.gz" + tar -czf "${archive}" -C "$(dirname -- "${OUTPUT_DIR}")" "$(basename -- "${OUTPUT_DIR}")" 2>/dev/null || true + printf 'Diagnostic archive: %s\n' "${archive}" + fi + printf 'Diagnostic directory: %s\n' "${OUTPUT_DIR}" + + if [[ "${status}" -eq 0 && "${#FAILED_CHECKS[@]}" -gt 0 ]]; then + status=1 + fi + exit "${status}" +} + +if [[ $# -eq 0 ]]; then + usage >&2 + exit 2 +fi +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage + exit 0 +fi + +INSTALL_DIR="$1" +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --container) + CONTAINER="$2" + shift 2 + ;; + --full-config) + FULL_CONFIG=1 + shift + ;; + --no-nginx-test) + NGINX_TEST=0 + shift + ;; + --no-archive) + CREATE_ARCHIVE=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -d "${INSTALL_DIR}" ]] || { + printf 'Install directory not found: %s\n' "${INSTALL_DIR}" >&2 + exit 2 +} +INSTALL_DIR="$(cd -- "${INSTALL_DIR}" && pwd -P)" +if [[ -d "${OUTPUT_DIR}" ]] && find "${OUTPUT_DIR}" -mindepth 1 -print -quit | grep -q .; then + printf 'Output directory must be empty: %s\n' "${OUTPUT_DIR}" >&2 + exit 2 +fi +mkdir -p "${OUTPUT_DIR}" +OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd -P)" + +trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR +trap finalize EXIT + +preflight() { + [[ "$(uname -s)" == "Linux" ]] || { + printf 'This diagnostic script must run on Linux.\n' >&2 + exit 2 + } + require_command docker + require_command jq + require_command python3 + require_command sha256sum + require_command tar + + docker version >"${OUTPUT_DIR}/docker-version.txt" 2>&1 + docker info >"${OUTPUT_DIR}/docker-info.txt" 2>&1 + uname -a >"${OUTPUT_DIR}/uname.txt" + cp /etc/os-release "${OUTPUT_DIR}/os-release.txt" 2>/dev/null || true + df -h >"${OUTPUT_DIR}/disk-free.txt" + free -h >"${OUTPUT_DIR}/memory.txt" 2>&1 || true + log "Inspecting ${INSTALL_DIR}" +} + +collect_filesystem_state() { + [[ -d "${INSTALL_DIR}/modules" ]] || mark_failed "module artifact directory is missing" + [[ -d "${INSTALL_DIR}/conf/modules-enabled" ]] || mark_failed "managed module config directory is missing" + find "${INSTALL_DIR}/modules" -maxdepth 5 -printf '%M\t%u:%g\t%s\t%TY-%Tm-%TdT%TH:%TM:%TS\t%p\n' \ + >"${OUTPUT_DIR}/module-files.txt" 2>&1 || true + find "${INSTALL_DIR}/conf/modules-enabled" -maxdepth 1 -type f -printf '%f\n' \ + >"${OUTPUT_DIR}/managed-config-files.txt" 2>&1 || true + grep -RnsE '^[[:space:]]*load_module[[:space:]]+' "${INSTALL_DIR}/conf/modules-enabled" \ + >"${OUTPUT_DIR}/load-module-directives.txt" 2>&1 || true + grep -E '^(RESTY_|CONTAINER_NAME=|PANEL_APP_PORT_HTTP=)' "${INSTALL_DIR}/.env" \ + >"${OUTPUT_DIR}/relevant-env.txt" 2>/dev/null || true + + if [[ -f "${INSTALL_DIR}/build/module.json" ]]; then + jq 'map(if has("script") then .script = "" else . end)' \ + "${INSTALL_DIR}/build/module.json" >"${OUTPUT_DIR}/module-state.json" + else + mark_failed "module state file is missing" + fi + if [[ -f "${INSTALL_DIR}/build/module.catalog.json" ]]; then + jq 'map(if has("script") then .script = "" else . end)' \ + "${INSTALL_DIR}/build/module.catalog.json" >"${OUTPUT_DIR}/module-catalog.json" + fi +} + +validate_artifacts() { + local state="${INSTALL_DIR}/build/module.json" + [[ -f "${state}" ]] || return 0 + if python3 - "${state}" "${INSTALL_DIR}/modules" >"${OUTPUT_DIR}/artifact-validation.tsv" <<'PY' +import hashlib +import json +import os +import pathlib +import sys + +state_path = pathlib.Path(sys.argv[1]) +modules_root = pathlib.Path(sys.argv[2]).resolve() +modules = json.loads(state_path.read_text(encoding="utf-8")) +failed = False +print("module\tbuild_status\ttarget_key\tartifact\texpected\tactual\tresult") +for module in modules: + for build in module.get("builds") or []: + target_key = (build.get("target") or {}).get("key", "") + for artifact in build.get("artifacts") or []: + relative = artifact.get("path", "") + expected = artifact.get("checksum", "") + result = "OK" + actual = "" + try: + pure = pathlib.PurePosixPath(relative) + if not relative or pure.is_absolute() or ".." in pure.parts or "\\" in relative: + raise ValueError("unsafe-path") + candidate = modules_root / pathlib.Path(*pure.parts) + if candidate.is_symlink(): + raise ValueError("symlink-not-allowed") + full_path = candidate.resolve(strict=True) + if modules_root not in full_path.parents: + raise ValueError("outside-module-root") + if not full_path.is_file(): + raise ValueError("not-regular-file") + digest = hashlib.sha256() + with full_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + actual = digest.hexdigest() + if actual.lower() != expected.lower(): + raise ValueError("checksum-mismatch") + except Exception as error: + result = str(error) + failed = True + print("\t".join([ + module.get("name", ""), build.get("status", ""), target_key, + relative, expected, actual, result, + ])) +sys.exit(1 if failed else 0) +PY + then + mark_passed "artifact paths and checksums" + else + mark_failed "artifact paths or checksums" + fi +} + +validate_managed_configs() { + if python3 - "${INSTALL_DIR}/conf/modules-enabled" "${INSTALL_DIR}/modules" \ + >"${OUTPUT_DIR}/managed-config-validation.tsv" <<'PY' +import pathlib +import re +import sys + +config_root = pathlib.Path(sys.argv[1]) +modules_root = pathlib.Path(sys.argv[2]).resolve() +container_prefix = "/usr/local/openresty/nginx/modules/1panel/" +pattern = re.compile(r"^\s*load_module\s+([^;]+);", re.MULTILINE) +failed = False +print("config\tcontainer_path\thost_path\tresult") +if config_root.exists(): + for config in sorted(config_root.glob("1panel-module-*.conf")): + content = config.read_text(encoding="utf-8") + for value in pattern.findall(content): + container_path = value.strip().strip('"\'') + result = "OK" + host_path = "" + try: + if not container_path.startswith(container_prefix): + raise ValueError("unexpected-container-path") + relative = pathlib.PurePosixPath(container_path[len(container_prefix):]) + if ".." in relative.parts: + raise ValueError("unsafe-path") + resolved = (modules_root / pathlib.Path(*relative.parts)).resolve(strict=True) + if modules_root not in resolved.parents or not resolved.is_file(): + raise ValueError("missing-artifact") + host_path = str(resolved) + except Exception as error: + result = str(error) + failed = True + print("\t".join([config.name, container_path, host_path, result])) +sys.exit(1 if failed else 0) +PY + then + mark_passed "managed load_module configs" + else + mark_failed "managed load_module configs" + fi +} + +collect_compose_state() { + local compose_file="${INSTALL_DIR}/docker-compose.yml" + if [[ ! -f "${compose_file}" ]]; then + mark_failed "docker-compose.yml is missing" + return 0 + fi + + if (cd "${INSTALL_DIR}" && docker compose config --format json) >"${OUTPUT_DIR}/compose.json" 2>"${OUTPUT_DIR}/compose-config.log"; then + mark_passed "docker compose config" + else + mark_failed "docker compose config" + return 0 + fi + + if jq -e ' + [.services[].volumes[]?] + | (any(.[]; (.target | rtrimstr("/")) == "/usr/local/openresty/nginx/modules/1panel" and .read_only == true)) + and (any(.[]; (.target | rtrimstr("/")) == "/usr/local/openresty/nginx/conf/modules-enabled" and .read_only == true)) + ' "${OUTPUT_DIR}/compose.json" >/dev/null; then + mark_passed "read-only module mounts" + else + mark_failed "read-only module mounts" + fi + + (cd "${INSTALL_DIR}" && docker compose ps -a --format json) >"${OUTPUT_DIR}/compose-ps.json" 2>&1 || true + if [[ -z "${CONTAINER}" ]]; then + local cid + cid="$(cd "${INSTALL_DIR}" && docker compose ps -q 2>/dev/null | head -n 1)" + if [[ -n "${cid}" ]]; then + CONTAINER="$(docker inspect --format '{{.Name}}' "${cid}" | sed 's#^/##')" + else + CONTAINER="$(jq -r '[.services[] | select((.image // "") | test("openresty"; "i")) | .container_name][0] // empty' \ + "${OUTPUT_DIR}/compose.json")" + fi + fi +} + +compare_target_identity() { + local state="${INSTALL_DIR}/build/module.json" + local current_image_id="$1" + [[ -f "${state}" ]] || return 0 + if python3 - "${state}" "${current_image_id}" "${INSTALL_DIR}/conf/modules-enabled" \ + >"${OUTPUT_DIR}/target-identity.tsv" <<'PY' +import json +import pathlib +import re +import sys + +modules = json.load(open(sys.argv[1], encoding="utf-8")) +current = sys.argv[2] +config_root = pathlib.Path(sys.argv[3]) +pattern = re.compile(r"^\s*load_module\s+([^;]+);", re.MULTILINE) +prefix = "/usr/local/openresty/nginx/modules/1panel/" +loaded = set() +if config_root.exists(): + for config in config_root.glob("1panel-module-*.conf"): + for value in pattern.findall(config.read_text(encoding="utf-8")): + container_path = value.strip().strip('"\'') + if container_path.startswith(prefix): + loaded.add(container_path[len(prefix):]) +failed = False +print("module\tenabled\tmode\tready_image_ids\tmanaged_artifacts\tresult") +for module in modules: + mode = module.get("buildMode") or "static" + if module.get("deleted") or not module.get("enable") or mode == "static": + continue + ready = [build for build in module.get("builds") or [] if build.get("status") == "ready"] + digests = sorted({(build.get("target") or {}).get("imageDigest", "") for build in ready}) + candidates = [] + if not ready: + result = "NO-READY-BUILD" + failed = True + elif current in digests: + result = "MATCH" + candidates = [build for build in ready if (build.get("target") or {}).get("imageDigest") == current] + elif not any(digests): + result = "UNKNOWN-NO-IMAGE-DIGEST" + candidates = ready + else: + result = "MISMATCH" + failed = True + + managed = [] + if candidates: + for build in candidates: + paths = [artifact.get("path", "") for artifact in build.get("artifacts") or []] + if paths and all(path in loaded for path in paths): + managed = paths + break + if not managed: + result += "+NOT-IN-MANAGED-CONFIG" + failed = True + print("\t".join([ + module.get("name", ""), str(module.get("enable", False)), + mode, ",".join(digests), ",".join(managed), result, + ])) +sys.exit(1 if failed else 0) +PY + then + mark_passed "enabled module target image identity" + else + mark_failed "enabled module target image identity" + fi +} + +collect_container_state() { + if [[ -z "${CONTAINER}" ]]; then + mark_failed "OpenResty container could not be discovered" + return 0 + fi + if ! docker inspect "${CONTAINER}" >/dev/null 2>&1; then + mark_failed "container ${CONTAINER} does not exist" + return 0 + fi + + docker inspect "${CONTAINER}" | jq '.[0] | { + Id, Name, Image, State, + Config: {Image: .Config.Image}, + Mounts: [.Mounts[] | {Type, Source, Destination, RW}] + }' >"${OUTPUT_DIR}/container.json" + docker logs --tail 1000 --timestamps "${CONTAINER}" >"${OUTPUT_DIR}/container.log" 2>&1 || true + + local running image_id image_name + running="$(docker inspect --format '{{.State.Running}}' "${CONTAINER}")" + image_id="$(docker inspect --format '{{.Image}}' "${CONTAINER}")" + image_name="$(docker inspect --format '{{.Config.Image}}' "${CONTAINER}")" + docker image inspect "${image_id}" | jq '.[0] | {Id, RepoTags, RepoDigests, Architecture, Os, Created}' \ + >"${OUTPUT_DIR}/runtime-image.json" 2>&1 || true + printf 'container=%s\nrunning=%s\nimage_name=%s\nimage_id=%s\n' \ + "${CONTAINER}" "${running}" "${image_name}" "${image_id}" >"${OUTPUT_DIR}/runtime.txt" + compare_target_identity "${image_id}" + + if [[ "${running}" != "true" ]]; then + mark_failed "container ${CONTAINER} is not running" + return 0 + fi + mark_passed "container is running" + + docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -V \ + >"${OUTPUT_DIR}/nginx-version.txt" 2>&1 || mark_failed "nginx -V" + docker exec "${CONTAINER}" /bin/sh -c \ + 'find /usr/local/openresty/nginx/modules/1panel -type f -name "*.so" -exec sha256sum {} \; | sort' \ + >"${OUTPUT_DIR}/container-artifact-checksums.txt" 2>&1 || true + docker exec "${CONTAINER}" /bin/sh -c \ + 'for f in $(find /usr/local/openresty/nginx/modules/1panel -type f -name "*.so" | sort); do echo "### $f"; ldd "$f" || true; done' \ + >"${OUTPUT_DIR}/container-artifact-ldd.txt" 2>&1 || true + + if [[ "${NGINX_TEST}" -eq 1 ]]; then + if docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -t \ + >"${OUTPUT_DIR}/nginx-test.txt" 2>&1; then + mark_passed "running container nginx -t" + else + mark_failed "running container nginx -t" + fi + + local full_output="${OUTPUT_DIR}/nginx-T.full.tmp" + docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -T >"${full_output}" 2>&1 || true + if [[ "${FULL_CONFIG}" -eq 1 ]]; then + mv "${full_output}" "${OUTPUT_DIR}/nginx-T.full.txt" + log "WARNING: nginx-T.full.txt may contain credentials or private configuration" + else + grep -nE 'load_module|modules-enabled|nginx version:|configure arguments:' "${full_output}" \ + >"${OUTPUT_DIR}/nginx-T-modules.txt" 2>/dev/null || true + rm -f "${full_output}" + fi + fi +} + +main() { + preflight + collect_filesystem_state + validate_artifacts + validate_managed_configs + collect_compose_state + collect_container_state + + if [[ "${#FAILED_CHECKS[@]}" -gt 0 ]]; then + log "Diagnostics completed with ${#FAILED_CHECKS[@]} failed checks" + return 0 + fi + log "Diagnostics completed without failed checks" +} + +main "$@" diff --git a/scripts/openresty-modules/test-builder.sh b/scripts/openresty-modules/test-builder.sh new file mode 100644 index 000000000000..824617b5e676 --- /dev/null +++ b/scripts/openresty-modules/test-builder.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +export DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd -P)" +DEFAULT_APPSTORE_ROOT="$(cd -- "${REPO_ROOT}/.." && pwd -P)/appstore" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" + +APPSTORE_ROOT="${APPSTORE_ROOT:-${DEFAULT_APPSTORE_ROOT}}" +VERSIONS_CSV="1.27.1.2-5-1-focal,1.29.2.5-0-noble,1.31.1.1-0-noble" +MODULES_CSV="" +OUTPUT_DIR="${OUTPUT_DIR:-${PWD}/openresty-module-test-results/${RUN_ID}}" +SKIP_PULL=0 +NO_CACHE=0 +KEEP_DOCKER=0 +KEEP_CONTEXT=0 +RUN_SOURCE_CHECKS=0 +STRICT_INDIVIDUAL=0 +CLEANUP_READY=0 + +declare -a CREATED_CONTAINERS=() +declare -a CREATED_IMAGES=() +declare -a VERSIONS=() +declare -a REQUESTED_MODULES=() + +usage() { + cat <<'EOF' +Usage: test-builder.sh [options] + +Build and load-test OpenResty dynamic modules directly from the appstore tree. + +Options: + --appstore PATH Appstore repository root (default: sibling appstore repo) + --versions CSV App versions to test + --modules CSV Module names to test (default: every catalog module) + --output PATH Persistent result directory + --skip-pull Use local runtime images without pulling + --no-cache Pass --no-cache to every module Docker build + --strict-individual Fail when a module cannot load by itself + --source-checks Run Go module tests and go vet before Docker tests + --keep-docker Keep temporary images and containers + --keep-context Keep copied Docker build contexts + -h, --help Show this help + +Environment equivalents: APPSTORE_ROOT, OUTPUT_DIR, DOCKER_BUILDKIT. +EOF +} + +log() { + printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "${OUTPUT_DIR}/run.log" +} + +die() { + log "ERROR: $*" + return 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +split_csv() { + local value="$1" + local -n destination="$2" + IFS=',' read -r -a destination <<<"${value}" +} + +safe_name() { + local value="$1" + local base digest + base="$(printf '%s' "${value}" | sed -E 's/[^a-zA-Z0-9._-]+/-/g; s/^-+//; s/-+$//' | cut -c1-48)" + [[ -n "${base}" ]] || base="module" + digest="$(printf '%s' "${value}" | sha256sum | awk '{print substr($1,1,8)}')" + printf '%s-%s' "${base}" "${digest}" +} + +run_logged() { + local log_file="$1" + shift + mkdir -p "$(dirname -- "${log_file}")" + set +e + "$@" > >(tee "${log_file}") 2>&1 + local status=$? + set -e + return "${status}" +} + +docker_rm_container() { + local name="$1" + docker rm -f "${name}" >/dev/null 2>&1 || true +} + +cleanup() { + local status=$? + if [[ "${CLEANUP_READY}" -eq 0 ]]; then + exit "${status}" + fi + if [[ "${KEEP_DOCKER}" -eq 0 ]]; then + local item + for item in "${CREATED_CONTAINERS[@]:-}"; do + [[ -n "${item}" ]] && docker_rm_container "${item}" + done + for item in "${CREATED_IMAGES[@]:-}"; do + [[ -n "${item}" ]] && docker image rm -f "${item}" >/dev/null 2>&1 || true + done + fi + if [[ "${KEEP_CONTEXT}" -eq 0 && -d "${OUTPUT_DIR}/work" ]]; then + find "${OUTPUT_DIR}/work" -mindepth 2 -maxdepth 2 -type d -name context -prune -exec rm -rf -- {} + 2>/dev/null || true + fi + exit "${status}" +} + +write_debug_bundle() { + local status="$1" line="$2" command="$3" + { + printf 'exit_status=%s\n' "${status}" + printf 'line=%s\n' "${line}" + printf 'command=%s\n' "${command}" + printf 'run_id=%s\n' "${RUN_ID}" + } >"${OUTPUT_DIR}/failure.txt" + + docker ps -a --no-trunc >"${OUTPUT_DIR}/docker-ps.txt" 2>&1 || true + docker image ls --digests --no-trunc >"${OUTPUT_DIR}/docker-images.txt" 2>&1 || true + docker system df >"${OUTPUT_DIR}/docker-system-df.txt" 2>&1 || true + + local item + for item in "${CREATED_CONTAINERS[@]:-}"; do + [[ -n "${item}" ]] || continue + docker inspect "${item}" >"${OUTPUT_DIR}/container-${item}.json" 2>&1 || true + docker logs "${item}" >"${OUTPUT_DIR}/container-${item}.log" 2>&1 || true + done + + local archive="${OUTPUT_DIR%/}.tar.gz" + tar --exclude='*/context' -czf "${archive}" -C "$(dirname -- "${OUTPUT_DIR}")" "$(basename -- "${OUTPUT_DIR}")" 2>/dev/null || true + printf 'Debug bundle: %s\n' "${archive}" >&2 +} + +on_error() { + local status="$1" line="$2" command="$3" + set +e + log "FAILED at line ${line}: ${command} (exit ${status})" + write_debug_bundle "${status}" "${line}" "${command}" + return "${status}" +} + +trap cleanup EXIT + +while [[ $# -gt 0 ]]; do + case "$1" in + --appstore) + APPSTORE_ROOT="$2" + shift 2 + ;; + --versions) + VERSIONS_CSV="$2" + shift 2 + ;; + --modules) + MODULES_CSV="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --skip-pull) + SKIP_PULL=1 + shift + ;; + --no-cache) + NO_CACHE=1 + shift + ;; + --strict-individual) + STRICT_INDIVIDUAL=1 + shift + ;; + --source-checks) + RUN_SOURCE_CHECKS=1 + shift + ;; + --keep-docker) + KEEP_DOCKER=1 + shift + ;; + --keep-context) + KEEP_CONTEXT=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -d "${OUTPUT_DIR}" ]] && find "${OUTPUT_DIR}" -mindepth 1 -print -quit | grep -q .; then + printf 'Output directory must be empty: %s\n' "${OUTPUT_DIR}" >&2 + exit 2 +fi +mkdir -p "${OUTPUT_DIR}/work" +OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd -P)" +CLEANUP_READY=1 +trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR +APPSTORE_ROOT="$(cd -- "${APPSTORE_ROOT}" && pwd -P)" +split_csv "${VERSIONS_CSV}" VERSIONS +if [[ "${#VERSIONS[@]}" -eq 0 || -z "${VERSIONS[0]}" ]]; then + die "at least one OpenResty version is required" +fi +if [[ -n "${MODULES_CSV}" ]]; then + split_csv "${MODULES_CSV}" REQUESTED_MODULES +fi + +preflight() { + [[ "$(uname -s)" == "Linux" ]] || die "this integration test must run on Linux" + require_command docker + require_command jq + require_command python3 + require_command sha256sum + require_command sed + require_command awk + require_command tar + require_command file + require_command readelf + + docker version >"${OUTPUT_DIR}/docker-version.txt" 2>&1 + docker info >"${OUTPUT_DIR}/docker-info.txt" 2>&1 + docker compose version >"${OUTPUT_DIR}/docker-compose-version.txt" 2>&1 + uname -a >"${OUTPUT_DIR}/uname.txt" + cp /etc/os-release "${OUTPUT_DIR}/os-release.txt" 2>/dev/null || true + df -h >"${OUTPUT_DIR}/disk-free.txt" + free -h >"${OUTPUT_DIR}/memory.txt" 2>&1 || true + + [[ -d "${APPSTORE_ROOT}/apps/openresty" ]] || die "invalid appstore root: ${APPSTORE_ROOT}" + log "Results: ${OUTPUT_DIR}" + log "Appstore: ${APPSTORE_ROOT}" + log "Docker architecture: $(docker info --format '{{.Architecture}}')" +} + +run_source_checks() { + [[ "${RUN_SOURCE_CHECKS}" -eq 1 ]] || return 0 + require_command go + log "Running Go dynamic-module tests" + run_logged "${OUTPUT_DIR}/go-test.log" bash -c \ + "cd '${REPO_ROOT}/agent' && go test ./app/service -run 'NginxModule|DynamicModule' -count=1 -v" + log "Running go vet" + run_logged "${OUTPUT_DIR}/go-vet.log" bash -c "cd '${REPO_ROOT}/agent' && go vet ./..." +} + +validate_template() { + local version="$1" + local app_dir="${APPSTORE_ROOT}/apps/openresty/${version}" + local catalog="${app_dir}/build/module.catalog.json" + + [[ "${version}" =~ ^[a-zA-Z0-9._-]+$ ]] || die "unsafe version value: ${version}" + [[ -f "${app_dir}/build/Dockerfile.modules" ]] || die "missing Dockerfile.modules for ${version}" + [[ -f "${catalog}" ]] || die "missing module catalog for ${version}" + jq -e 'type == "array" and length > 0 and all(.[]; .name and .params and .provider == "local")' \ + "${catalog}" >/dev/null + bash -n "${app_dir}/scripts/init.sh" + bash -n "${app_dir}/scripts/upgrade.sh" + grep -Fq 'conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled/:ro' "${app_dir}/docker-compose.yml" + grep -Fq './modules:/usr/local/openresty/nginx/modules/1panel/:ro' "${app_dir}/docker-compose.yml" + grep -Fq 'include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;' "${app_dir}/conf/nginx.conf" + + mkdir -p "${OUTPUT_DIR}/work/${version}/website/conf.d" "${OUTPUT_DIR}/work/${version}/website/stream.d" + ( + cd "${app_dir}" + CONTAINER_NAME="openresty-template-check" \ + WEBSITE_DIR="${OUTPUT_DIR}/work/${version}/website" \ + PANEL_APP_PORT_HTTP=18080 \ + docker compose config -q + ) >"${OUTPUT_DIR}/work/${version}/compose-config.log" 2>&1 +} + +write_module_inputs() { + local catalog="$1" module="$2" context="$3" input_dir="$4" + local module_script params dynamic_params packages + + module_script="$(jq -er --arg name "${module}" '.[] | select(.name == $name) | .script' "${catalog}")" + params="$(jq -er --arg name "${module}" '.[] | select(.name == $name) | .params' "${catalog}")" + packages="$(jq -er --arg name "${module}" '.[] | select(.name == $name) | (.packages // []) | join(" ")' "${catalog}")" + dynamic_params="${params//--add-module=/--add-dynamic-module=}" + + mkdir -p "${input_dir}" + printf '%s\n' "${module_script}" >"${input_dir}/script.txt" + printf '%s\n' "${params}" >"${input_dir}/params.original.txt" + printf '%s\n' "${dynamic_params}" >"${input_dir}/params.dynamic.txt" + printf '%s\n' "${packages}" >"${input_dir}/packages.txt" + printf '#!/bin/bash\nset -e\n%s\n' "${module_script}" >"${context}/tmp/module-pre.sh" + + python3 - "${dynamic_params}" >"${context}/tmp/module-config.args" <<'PY' +import shlex +import sys + +params = sys.argv[1] +args = shlex.split(params, posix=True) +if not args: + raise SystemExit("dynamic module parameters are empty") +if not any(arg.startswith("--add-dynamic-module=") or "=dynamic" in arg for arg in args): + raise SystemExit("module does not declare a dynamic configure option") +for arg in args: + if not arg.startswith("--"): + raise SystemExit(f"unsupported configure argument: {arg!r}") + if any(char in arg for char in "\x00\r\n;&|<>"): + raise SystemExit(f"unsafe configure argument: {arg!r}") + print(arg) +PY +} + +validate_load_directives() { + local image="$1" modules_root="$2" directives_file="$3" config_path="$4" log_file="$5" + { + cat "${directives_file}" + printf 'error_log stderr notice;\npid /tmp/nginx.pid;\nevents {}\nhttp {}\n' + } >"${config_path}" + + run_logged "${log_file}" docker run --rm --network none \ + -v "${modules_root}:/usr/local/openresty/nginx/modules/1panel:ro" \ + -v "${config_path}:/tmp/1panel-module-test.conf:ro" \ + --entrypoint /usr/local/openresty/nginx/sbin/nginx \ + "${image}" -t -c /tmp/1panel-module-test.conf +} + +build_module() { + local version="$1" module="$2" image="$3" app_dir="$4" version_dir="$5" context="$6" sequence="$7" + local catalog="${app_dir}/build/module.catalog.json" + local module_key module_dir input_dir tag build_log cid packages artifact relative checksum + local -a artifacts=() + module_key="$(safe_name "${module}")" + module_dir="${version_dir}/modules/${module_key}/${RUN_ID}" + input_dir="${version_dir}/inputs/${module_key}" + tag="1panel/openresty-module-test:${module_key}-$(safe_name "${version}")-${RUN_ID}" + tag="${tag:0:127}" + build_log="${version_dir}/logs/build-${module_key}.log" + + log "[${version}] building ${module}" + write_module_inputs "${catalog}" "${module}" "${context}" "${input_dir}" + packages="$(cat "${input_dir}/packages.txt")" + + local -a build_args=( + build --progress=plain --target module-output + -f "${context}/Dockerfile.modules" + -t "${tag}" + --build-arg "PANEL_OPENRESTY_VERSION=${version}" + --build-arg "RESTY_ADD_PACKAGE_BUILDDEPS=${packages}" + ) + [[ "${NO_CACHE}" -eq 0 ]] || build_args+=(--no-cache) + build_args+=("${context}") + + run_logged "${build_log}" docker "${build_args[@]}" + CREATED_IMAGES+=("${tag}") + + cid="1panel-module-copy-${module_key}-${RUN_ID}" + cid="${cid:0:63}" + docker create --name "${cid}" "${tag}" /bin/true >"${input_dir}/container-id.txt" + CREATED_CONTAINERS+=("${cid}") + mkdir -p "${module_dir}" + docker cp "${cid}:/out/." "${module_dir}" + docker_rm_container "${cid}" + + mapfile -t artifacts < <(find "${module_dir}" -maxdepth 1 -type f -name '*.so' -print | sort) + [[ "${#artifacts[@]}" -gt 0 ]] || die "${module} produced no top-level .so files" + + : >"${input_dir}/load-directives.conf" + for artifact in "${artifacts[@]}"; do + relative="${artifact#${version_dir}/modules/}" + [[ "${relative}" =~ ^[a-zA-Z0-9_./+-]+\.so$ ]] || \ + die "unsafe module artifact path: ${relative}" + checksum="$(sha256sum "${artifact}" | awk '{print $1}')" + printf '%s\t%s\t%s\t%s\n' "${module}" "${relative}" "${checksum}" "$(stat -c '%s' "${artifact}")" \ + >>"${version_dir}/artifacts.tsv" + printf 'load_module /usr/local/openresty/nginx/modules/1panel/%s;\n' "${relative}" \ + >>"${input_dir}/load-directives.conf" + file "${artifact}" >>"${input_dir}/file.txt" + readelf -d "${artifact}" >>"${input_dir}/readelf-dynamic.txt" 2>&1 || true + done + + if ! validate_load_directives "${image}" "${version_dir}/modules" \ + "${input_dir}/load-directives.conf" "${input_dir}/individual-nginx.conf" \ + "${version_dir}/logs/load-${module_key}.log"; then + printf '%s\tindividual-load-failed\n' "${module}" >>"${version_dir}/status.tsv" + if [[ "${STRICT_INDIVIDUAL}" -eq 1 ]]; then + die "${module} failed individual load validation" + fi + log "[${version}] ${module} cannot load alone; combined validation will decide" + else + printf '%s\tindividual-load-ok\n' "${module}" >>"${version_dir}/status.tsv" + fi + + cat "${input_dir}/load-directives.conf" >>"${version_dir}/combined-load-directives.conf" + cp "${input_dir}/load-directives.conf" \ + "${version_dir}/ordered-configs/$(printf '%04d' "${sequence}")-${module_key}.conf" +} + +runtime_reload_test() { + local version="$1" image="$2" version_dir="$3" + local runtime_dir="${version_dir}/runtime" container="1panel-module-runtime-$(safe_name "${version}")-${RUN_ID}" + local -a module_configs=() + container="${container:0:63}" + mkdir -p "${runtime_dir}/modules-enabled" + + cat >"${runtime_dir}/nginx.conf" <<'EOF' +error_log stderr notice; +pid /tmp/nginx.pid; +include /tmp/modules-enabled/*.conf; +events {} +http {} +EOF + printf '# empty initial module set\n' >"${runtime_dir}/modules-enabled/0000-empty.conf" + + mapfile -t module_configs < <(find "${version_dir}/ordered-configs" -type f -name '*.conf' -print | sort) + [[ "${#module_configs[@]}" -gt 0 ]] || die "no module configs available for runtime test" + + log "[${version}] starting runtime reload test" + docker run -d --name "${container}" --network none \ + -v "${version_dir}/modules:/usr/local/openresty/nginx/modules/1panel:ro" \ + -v "${runtime_dir}/modules-enabled:/tmp/modules-enabled:ro" \ + -v "${runtime_dir}/nginx.conf:/tmp/1panel-runtime-nginx.conf:ro" \ + --entrypoint /usr/local/openresty/nginx/sbin/nginx \ + "${image}" -c /tmp/1panel-runtime-nginx.conf -g 'daemon off;' \ + >"${runtime_dir}/container-id.txt" + CREATED_CONTAINERS+=("${container}") + + local attempt + for ((attempt = 1; attempt <= 20; attempt++)); do + if [[ "$(docker inspect --format '{{.State.Running}}' "${container}" 2>/dev/null || true)" == "true" ]]; then + break + fi + sleep 1 + done + if [[ "$(docker inspect --format '{{.State.Running}}' "${container}" 2>/dev/null || true)" != "true" ]]; then + docker logs "${container}" >"${runtime_dir}/startup-failure.log" 2>&1 || true + die "runtime container failed to start" + fi + + local config + for config in "${module_configs[@]}"; do + cp "${config}" "${runtime_dir}/modules-enabled/$(basename -- "${config}")" + done + run_logged "${runtime_dir}/nginx-test.log" docker exec "${container}" \ + /usr/local/openresty/nginx/sbin/nginx -t -c /tmp/1panel-runtime-nginx.conf + run_logged "${runtime_dir}/nginx-reload.log" docker exec "${container}" \ + /usr/local/openresty/nginx/sbin/nginx -s reload -c /tmp/1panel-runtime-nginx.conf + + printf 'load_module /usr/local/openresty/nginx/modules/1panel/not-found.so;\n' \ + >"${runtime_dir}/modules-enabled/9999-invalid.conf" + if docker exec "${container}" /usr/local/openresty/nginx/sbin/nginx \ + -t -c /tmp/1panel-runtime-nginx.conf >"${runtime_dir}/expected-invalid.log" 2>&1; then + die "nginx -t unexpectedly accepted a missing module" + fi + rm -f "${runtime_dir}/modules-enabled/9999-invalid.conf" + run_logged "${runtime_dir}/rollback-nginx-test.log" docker exec "${container}" \ + /usr/local/openresty/nginx/sbin/nginx -t -c /tmp/1panel-runtime-nginx.conf + [[ "$(docker inspect --format '{{.State.Running}}' "${container}")" == "true" ]] || \ + die "runtime container stopped during rollback test" + + docker logs "${container}" >"${runtime_dir}/container.log" 2>&1 || true + docker exec "${container}" /bin/sh -c \ + 'for f in /usr/local/openresty/nginx/modules/1panel/*/*/*.so; do echo "### $f"; ldd "$f" || true; done' \ + >"${runtime_dir}/ldd.txt" 2>&1 || true + docker_rm_container "${container}" +} + +test_version() { + local version="$1" + local app_dir="${APPSTORE_ROOT}/apps/openresty/${version}" + local version_dir="${OUTPUT_DIR}/work/${version}" + local context="${version_dir}/context" + local catalog="${app_dir}/build/module.catalog.json" + local image="1panel/openresty:${version}" + local -a modules=() + + validate_template "${version}" + mkdir -p "${version_dir}/logs" "${version_dir}/inputs" "${version_dir}/modules" \ + "${version_dir}/ordered-configs" + cp -a "${app_dir}/build" "${context}" + : >"${version_dir}/artifacts.tsv" + : >"${version_dir}/status.tsv" + : >"${version_dir}/combined-load-directives.conf" + + if [[ "${#REQUESTED_MODULES[@]}" -gt 0 ]]; then + modules=("${REQUESTED_MODULES[@]}") + else + mapfile -t modules < <(jq -r 'sort_by([.loadOrder // 50, .name])[] | .name' "${catalog}") + fi + + if [[ "${SKIP_PULL}" -eq 0 ]]; then + log "[${version}] pulling ${image}" + run_logged "${version_dir}/logs/image-pull.log" docker pull "${image}" + fi + docker image inspect "${image}" >"${version_dir}/image-inspect.json" + run_logged "${version_dir}/logs/nginx-version.log" docker run --rm \ + --entrypoint /usr/local/openresty/nginx/sbin/nginx "${image}" -V + sha256sum "${app_dir}/build/Dockerfile.modules" >"${version_dir}/builder.sha256" + find "${app_dir}/build/tmp" -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha256sum \ + >"${version_dir}/build-inputs.sha256" + + local module sequence=0 + for module in "${modules[@]}"; do + sequence=$((sequence + 1)) + jq -e --arg name "${module}" 'any(.[]; .name == $name)' "${catalog}" >/dev/null || \ + die "module ${module} is not present in ${version} catalog" + build_module "${version}" "${module}" "${image}" "${app_dir}" "${version_dir}" "${context}" "${sequence}" + done + + log "[${version}] validating the combined load order" + validate_load_directives "${image}" "${version_dir}/modules" \ + "${version_dir}/combined-load-directives.conf" "${version_dir}/combined-nginx.conf" \ + "${version_dir}/logs/load-combined.log" + runtime_reload_test "${version}" "${image}" "${version_dir}" + printf '%s\tPASS\n' "${version}" >>"${OUTPUT_DIR}/summary.tsv" + log "[${version}] PASS" +} + +main() { + preflight + run_source_checks + printf 'version\tresult\n' >"${OUTPUT_DIR}/summary.tsv" + local version + for version in "${VERSIONS[@]}"; do + test_version "${version}" + done + log "All requested OpenResty module tests passed" + log "Summary: ${OUTPUT_DIR}/summary.tsv" +} + +main "$@" From 9081849df50dc8c2e33849a9569e59f4d77fc3bf Mon Sep 17 00:00:00 2001 From: Snrat Date: Sat, 18 Jul 2026 15:31:58 +0000 Subject: [PATCH 2/8] feat: add dynamic module build page for OpenResty --- frontend/src/api/interface/nginx.ts | 27 +++++- frontend/src/lang/modules/en.ts | 23 ++++- frontend/src/lang/modules/zh.ts | 23 ++++- .../website/nginx/module/build/index.vue | 49 ++++++---- .../website/website/nginx/module/index.vue | 47 +++++++++- .../website/nginx/module/operate/index.vue | 89 ++++++++++++++----- 6 files changed, 210 insertions(+), 48 deletions(-) diff --git a/frontend/src/api/interface/nginx.ts b/frontend/src/api/interface/nginx.ts index 0a1b6d48a419..29a87b982fb0 100644 --- a/frontend/src/api/interface/nginx.ts +++ b/frontend/src/api/interface/nginx.ts @@ -32,6 +32,14 @@ export namespace Nginx { export interface NginxBuildReq { taskID: string; mirror: string; + modules?: string[]; + force?: boolean; + } + + export interface NginxModuleArtifact { + name: string; + path: string; + checksum: string; } export interface NginxModule { @@ -40,6 +48,15 @@ export namespace Nginx { packages?: string; enable: boolean; params: string; + buildMode: 'auto' | 'dynamic' | 'static'; + provider: 'local' | 'prebuilt'; + dynamicSupport: 'unknown' | 'supported' | 'unsupported'; + loadOrder: number; + buildStatus: 'pending' | 'ready' | 'failed'; + loadStatus: 'enabled' | 'disabled'; + compatibility: 'unknown' | 'compatible' | 'stale' | 'static'; + artifacts?: NginxModuleArtifact[]; + lastError?: string; } export interface NginxBuildConfig { @@ -47,8 +64,16 @@ export namespace Nginx { modules: NginxModule[]; } - export interface NginxModuleUpdate extends NginxModule { + export interface NginxModuleUpdate { operate: string; + name: string; + script?: string; + packages?: string; + enable?: boolean; + params?: string; + buildMode?: 'auto' | 'dynamic' | 'static'; + provider?: 'local' | 'prebuilt'; + loadOrder?: number; } export interface NginxHttpsStatus { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index a05d77c4ed36..fe1126221b97 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -3751,14 +3751,31 @@ const message = { module: 'Modules', build: 'Build', buildWarn: - 'Building OpenResty requires reserving a certain amount of CPU and memory, which may take a long time, please be patient', + 'Local module builds use CPU and memory. Static modules also rebuild and restart OpenResty. Continue?', + buildMode: 'Build mode', + buildModeAuto: 'Auto (dynamic)', + buildModeDynamic: 'Dynamic', + buildModeStatic: 'Static', + buildStatus: 'Build status', + compatibility: 'Compatibility', + loadOrder: 'Load order', + modulesToBuild: 'Dynamic modules', + forceBuild: 'Rebuild without cache', + buildFailed: 'Last build failed', + pending: 'Pending', + ready: 'Ready', + failed: 'Failed', + unknown: 'Unknown', + compatible: 'Compatible', + stale: 'Rebuild required', + static: 'Static build', mirrorUrl: 'Software Source', - paramsHelper: 'For example: --add-module=/tmp/ngx_brotli', + paramsHelper: 'For example: --add-module=/tmp/ngx_brotli; dynamic mode converts it automatically', packagesHelper: 'For example: git, curl (separated by commas)', scriptHelper: 'Scripts to execute before compilation, usually for downloading module source code, installing dependencies, etc.', buildHelper: - 'Click build after adding/modifying a module. OpenResty will automatically restart upon successful build.', + 'Dynamic modules are loaded from configuration after building; static modules still rebuild OpenResty.', defaultHttps: 'HTTPS Anti-tampering', defaultHttpsHelper1: 'Enabling this can resolve HTTPS tampering issues.', sslRejectHandshake: 'Reject default SSL handshake', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 1e548eb44095..dd0641493d82 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3484,12 +3484,29 @@ const message = { script: '脚本', module: '模块', build: '构建', - buildWarn: '构建 OpenResty 需要预留一定的 CPU 和内存,时间较长,请耐心等待', + buildWarn: '本地构建模块需要占用一定的 CPU 和内存,静态模块还会重建并重启 OpenResty,是否继续?', + buildMode: '构建方式', + buildModeAuto: '自动(动态构建)', + buildModeDynamic: '动态模块', + buildModeStatic: '静态模块', + buildStatus: '构建状态', + compatibility: '兼容性', + loadOrder: '加载顺序', + modulesToBuild: '动态模块', + forceBuild: '忽略缓存重新构建', + buildFailed: '上次构建失败', + pending: '待构建', + ready: '已构建', + failed: '构建失败', + unknown: '未知', + compatible: '兼容', + stale: '需要重新构建', + static: '静态编译', mirrorUrl: '软件源', - paramsHelper: '例如:--add-module=/tmp/ngx_brotli', + paramsHelper: '例如:--add-module=/tmp/ngx_brotli,动态模式会自动转换参数', packagesHelper: '例如:git,curl 按,分割', scriptHelper: '编译之前执行的脚本,一般为下载模块源码,安装依赖等', - buildHelper: '添加/修改模块之后点击构建,构建成功后会自动重启 OpenResty', + buildHelper: '动态模块构建后通过配置加载;静态模块仍需重新构建 OpenResty', defaultHttps: 'HTTPS 防窜站', defaultHttpsHelper1: '开启后可以解决 HTTPS 窜站问题', sslRejectHandshake: '拒绝默认 SSL 握手', diff --git a/frontend/src/views/website/website/nginx/module/build/index.vue b/frontend/src/views/website/website/nginx/module/build/index.vue index c470681f4e00..3715479b1e3b 100644 --- a/frontend/src/views/website/website/nginx/module/build/index.vue +++ b/frontend/src/views/website/website/nginx/module/build/index.vue @@ -21,6 +21,16 @@ > + + + + {{ item.name }} + + + + + +