diff --git a/agent/app/service/runtime_update.go b/agent/app/service/runtime_update.go new file mode 100644 index 000000000..bd681fa45 --- /dev/null +++ b/agent/app/service/runtime_update.go @@ -0,0 +1,106 @@ +package service + +import ( + "path/filepath" + + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/utils/files" + "gopkg.in/yaml.v3" +) + +func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) { + current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID)) + if err != nil { + return model.AppDetail{}, err + } + if current.ID == 0 { + return model.AppDetail{}, buserr.New("ErrRecordNotFound") + } + opts := []repo.DBOption{appDetailRepo.WithAppId(current.AppId)} + if req.AppDetailID != 0 { + opts = append(opts, repo.WithByID(req.AppDetailID)) + } else if req.Version != "" { + opts = append(opts, appDetailRepo.WithVersion(req.Version)) + } else { + return current, nil + } + detail, err := appDetailRepo.GetFirst(opts...) + if err != nil { + return model.AppDetail{}, err + } + if detail.ID == 0 || (req.Version != "" && detail.Version != req.Version) { + return model.AppDetail{}, buserr.New("ErrInvalidParams") + } + return detail, nil +} + +func updateRuntimeVersionFiles(runtime *model.Runtime, appVersionDir string) error { + fileOp := files.NewFileOp() + template, err := fileOp.GetContent(filepath.Join(appVersionDir, "docker-compose.yml")) + if err != nil { + return err + } + current, err := fileOp.GetContent(runtime.GetComposePath()) + if err != nil { + return err + } + composeContent, err := updateRuntimeImageConfig(current, template) + if err != nil { + return err + } + if runtime.Type == constant.RuntimePHP { + if err = fileOp.CopyDir(filepath.Join(appVersionDir, "build"), runtime.GetPath()); err != nil { + return err + } + if err = fileOp.CopyFile(filepath.Join(appVersionDir, "data.yml"), runtime.GetPath()); err != nil { + return err + } + } else { + if err = fileOp.CopyFile(filepath.Join(appVersionDir, "run.sh"), runtime.GetPath()); err != nil { + return err + } + } + return fileOp.SaveFile(runtime.GetComposePath(), string(composeContent), constant.FilePerm) +} + +func updateRuntimeImageConfig(current, template []byte) ([]byte, error) { + var currentCompose, templateCompose map[string]interface{} + if err := yaml.Unmarshal(current, ¤tCompose); err != nil { + return nil, err + } + if err := yaml.Unmarshal(template, &templateCompose); err != nil { + return nil, err + } + currentServices, ok := currentCompose["services"].(map[string]interface{}) + if !ok || len(currentServices) != 1 { + return nil, buserr.New("ErrFileParse") + } + templateServices, ok := templateCompose["services"].(map[string]interface{}) + if !ok || len(templateServices) != 1 { + return nil, buserr.New("ErrFileParse") + } + // Keep container settings and mounts; only the image and build definition belong to the version. + for _, currentService := range currentServices { + service, ok := currentService.(map[string]interface{}) + if !ok { + return nil, buserr.New("ErrFileParse") + } + for _, templateService := range templateServices { + target, ok := templateService.(map[string]interface{}) + if !ok || (target["image"] == nil && target["build"] == nil) { + return nil, buserr.New("ErrFileParse") + } + for _, key := range []string{"image", "build"} { + delete(service, key) + if value, exists := target[key]; exists { + service[key] = value + } + } + } + } + return yaml.Marshal(currentCompose) +} diff --git a/frontend/src/utils/runtime-environment.ts b/frontend/src/utils/runtime-environment.ts new file mode 100644 index 000000000..ce27b291a --- /dev/null +++ b/frontend/src/utils/runtime-environment.ts @@ -0,0 +1,65 @@ +export interface EnvironmentEntry { + key: string; + value: string; +} + +export const parseEnvironment = (text: string) => { + const entries: EnvironmentEntry[] = []; + const lines = text + .replace(/^\uFEFF/, '') + .replace(/\r\n?/g, '\n') + .split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trimStart(); + if (!line || line.startsWith('#')) continue; + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (!match) return { entries: [], error: { line: i + 1, reason: 'envInvalidAssignment' } }; + const key = match[1]; + let value = match[2]; + const quote = value[0]; + if (quote === '"' || quote === "'") { + const startLine = i + 1; + let end = 1; + // Quoted values may span lines; never evaluate variable or command substitutions. + for (; ; end++) { + if (end >= value.length) { + if (++i >= lines.length) { + return { entries: [], error: { line: startLine, reason: 'envUnclosedQuote' } }; + } + value += '\n' + lines[i]; + } + if (value[end] === '\\' && (value[end + 1] === quote || value[end + 1] === '\\')) { + end++; + } else if (value[end] === quote) { + break; + } + } + const trailing = value.slice(end + 1).trim(); + if (trailing && !trailing.startsWith('#')) { + return { entries: [], error: { line: i + 1, reason: 'envUnexpectedText' } }; + } + value = value.slice(1, end); + if (quote === '"') { + value = value.replace(/\\([nrt"\\])/g, (_, char: string) => { + return ({ n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' } as Record)[char]; + }); + } else { + value = value.replace(/\\'/g, "'"); + } + } else { + value = value.startsWith('#') ? '' : value.replace(/\s+#.*$/, '').trimEnd(); + } + const previous = entries.find((entry) => entry.key === key); + if (previous) previous.value = value; + else entries.push({ key, value }); + } + return { entries, error: null }; +}; + +export const mergeEnvironments = (current: EnvironmentEntry[], incoming: EnvironmentEntry[]) => { + for (const entry of incoming) { + const existing = current.find((item) => item.key === entry.key); + if (existing) existing.value = entry.value; + else current.push({ ...entry }); + } +};