package release import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime" "strings" "time" "git.pcux.cn/xuans/Smail/internal/buildinfo" securefile "git.pcux.cn/xuans/Smail/utils/file" ) var ( ErrInvalidPair = errors.New("invalid release pair") ErrUpgradeRollback = errors.New("release upgrade rolled back") ErrUpgradeRollbackFailed = errors.New("release rollback incomplete; preserve backups for manual recovery") ErrUpgradeBackupConflict = errors.New("release recovery backup already exists") ErrUpgradeDirectory = errors.New("release target directory is invalid") ) type Manifest struct { Version string `json:"version"` GitHash string `json:"gitHash"` ProtocolVersion int `json:"protocolVersion"` CreatedAt string `json:"createdAt"` } type Pair struct { Main buildinfo.Info Admin buildinfo.Info } func NewManifest(pair Pair, now time.Time) (Manifest, error) { if err := buildinfo.ValidatePair(pair.Main, pair.Admin); err != nil { return Manifest{}, fmt.Errorf("%w: %v", ErrInvalidPair, err) } if now.IsZero() { now = time.Now().UTC() } return Manifest{ Version: pair.Main.Version, GitHash: pair.Main.GitHash, ProtocolVersion: pair.Main.ProtocolVersion, CreatedAt: now.UTC().Format(time.RFC3339), }, nil } func ValidateManifest(manifest Manifest, pair Pair) error { if err := buildinfo.ValidatePair(pair.Main, pair.Admin); err != nil { return fmt.Errorf("%w: %v", ErrInvalidPair, err) } if strings.TrimSpace(manifest.Version) == "" || manifest.ProtocolVersion != buildinfo.ProtocolVersion { return ErrInvalidPair } if _, err := time.Parse(time.RFC3339, manifest.CreatedAt); err != nil { return ErrInvalidPair } if manifest.Version != pair.Main.Version || manifest.ProtocolVersion != pair.Main.ProtocolVersion { return ErrInvalidPair } if known(manifest.GitHash) && known(pair.Main.GitHash) && manifest.GitHash != pair.Main.GitHash { return ErrInvalidPair } return nil } func InstallPair(mainSource, adminSource, targetDir string, pair Pair) error { if strings.TrimSpace(targetDir) == "" || filepath.Clean(targetDir) == "." { return ErrUpgradeDirectory } manifest, err := NewManifest(pair, time.Now().UTC()) if err != nil { return err } mainName, adminName := executableNames() if err := validateExecutableSource(mainSource); err != nil { return fmt.Errorf("validate main executable: %w", err) } if err := validateExecutableSource(adminSource); err != nil { return fmt.Errorf("validate admin executable: %w", err) } parent := filepath.Dir(filepath.Clean(targetDir)) if err := os.MkdirAll(parent, 0750); err != nil { return fmt.Errorf("create release parent: %w", err) } stage, err := os.MkdirTemp(parent, ".smail-release-") if err != nil { return fmt.Errorf("create release staging directory: %w", err) } defer os.RemoveAll(stage) if err := copyExecutable(mainSource, filepath.Join(stage, mainName), 0755); err != nil { return fmt.Errorf("stage main executable: %w", err) } if err := copyExecutable(adminSource, filepath.Join(stage, adminName), 0750); err != nil { return fmt.Errorf("stage admin executable: %w", err) } manifestPath := filepath.Join(stage, "release.json") if err := writeManifest(manifestPath, manifest); err != nil { return err } if err := syncDirectory(stage); err != nil { return err } if err := os.MkdirAll(targetDir, 0750); err != nil { return fmt.Errorf("create release target: %w", err) } return replacePair(stage, targetDir, []string{mainName, adminName, "release.json"}) } var ( renamePath = os.Rename syncReleaseDirectory = syncDirectory ) func replacePair(stage, targetDir string, names []string) error { // 先检查所有备份,不能因升级新版本而删除上次未完成恢复的证据。 for _, name := range names { target := filepath.Join(targetDir, name) if _, err := os.Lstat(target + ".previous"); err == nil { return fmt.Errorf("%w: %s", ErrUpgradeBackupConflict, name) } else if !os.IsNotExist(err) { return fmt.Errorf("inspect recovery backup %s: %w", name, err) } if info, err := os.Lstat(target); err == nil { if !info.Mode().IsRegular() { return fmt.Errorf("%w: %s", ErrUpgradeDirectory, name) } } else if !os.IsNotExist(err) { return fmt.Errorf("inspect release target %s: %w", name, err) } } committed := make(map[string]bool, len(names)) backups := make(map[string]string, len(names)) rollback := func(cause error) error { var failures []error for index := len(names) - 1; index >= 0; index-- { name := names[index] target := filepath.Join(targetDir, name) if committed[name] { if err := os.Remove(target); err != nil { failures = append(failures, fmt.Errorf("remove replacement %s: %w", name, err)) continue } } if backup, ok := backups[name]; ok { if err := renamePath(backup, target); err != nil { failures = append(failures, fmt.Errorf("restore %s: %w", name, err)) } } } if err := syncReleaseDirectory(targetDir); err != nil { failures = append(failures, fmt.Errorf("sync restored release: %w", err)) } if len(failures) != 0 { return errors.Join(cause, ErrUpgradeRollbackFailed, errors.Join(failures...)) } return errors.Join(cause, ErrUpgradeRollback) } for _, name := range names { target := filepath.Join(targetDir, name) if _, err := os.Stat(target); err == nil { backup := target + ".previous" if err := renamePath(target, backup); err != nil { return rollback(fmt.Errorf("backup %s: %w", name, err)) } backups[name] = backup } else if !os.IsNotExist(err) { return rollback(fmt.Errorf("inspect %s: %w", name, err)) } if err := renamePath(filepath.Join(stage, name), target); err != nil { return rollback(fmt.Errorf("install %s: %w", name, err)) } committed[name] = true } if err := syncReleaseDirectory(targetDir); err != nil { return rollback(fmt.Errorf("sync release target: %w", err)) } for _, backup := range backups { // 备份清理失败不回滚已经持久化的新版本,避免把升级结果置于不一致状态。 _ = os.Remove(backup) } return nil } func ReadManifest(targetDir string) (Manifest, error) { data, err := securefile.ReadRegularFile(filepath.Join(targetDir, "release.json"), 1<<20) if err != nil { return Manifest{}, err } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { return Manifest{}, fmt.Errorf("decode release manifest: %w", err) } return manifest, nil } func executableNames() (string, string) { if runtime.GOOS == "windows" { return "smail.exe", "smail-admin.exe" } return "smail", "smail-admin" } func validateExecutableSource(path string) error { input, err := securefile.OpenRegularFile(path, os.O_RDONLY, 0) if err != nil { return err } defer input.Close() info, err := input.Stat() if err != nil { return err } if !info.Mode().IsRegular() || info.Size() == 0 { return ErrInvalidPair } return nil } func copyExecutable(source, target string, mode os.FileMode) error { input, err := securefile.OpenRegularFile(source, os.O_RDONLY, 0) if err != nil { return err } defer input.Close() output, err := securefile.CreateExclusiveFile(target, mode) if err != nil { return err } if _, err := io.Copy(output, input); err != nil { _ = output.Close() return err } if err := output.Sync(); err != nil { _ = output.Close() return err } return output.Close() } func writeManifest(path string, manifest Manifest) error { data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("encode release manifest: %w", err) } data = append(data, '\n') if err := securefile.WriteRegularFile(path, data, 0600); err != nil { return fmt.Errorf("write release manifest: %w", err) } return nil } func syncDirectory(path string) error { if runtime.GOOS == "windows" { return nil } // #nosec G304 -- 该路径只来自发布流程创建并校验过的目录。 directory, err := os.Open(path) if err != nil { return err } defer directory.Close() return directory.Sync() } func known(value string) bool { return strings.TrimSpace(value) != "" && value != "unknown" && value != "dev" }