package hostnginx import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) // Snapshot 切换前后完整宿主 Nginx 配置的快照,用于恢复与补偿。 type Snapshot struct { // Content 配置文件的完整字节内容。 Content []byte // ActivePort 内容中处于活动状态的后端端口。 ActivePort int } // Controller 负责校验、原子替换并重载当前宿主 Nginx 配置。 type Controller struct { // configPath 宿主 Nginx 配置文件的绝对路径。 configPath string // nginxExecutable 用于配置校验的 Nginx 可执行文件绝对路径。 nginxExecutable string // runner 执行外部命令的抽象,便于测试时替换。 runner commandRunner } // NewController 要求显式提供配置文件和 Nginx 可执行文件路径。 func NewController(configPath string, nginxExecutable string) (*Controller, error) { return newController(configPath, nginxExecutable, execRunner{}) } // newController 校验各参数后创建 Controller。configPath、nginxExecutable、systemctlPath 必须是 // 干净绝对路径,nginxServiceName 必须非空且无首尾空白,runner 必须非空。 func newController(configPath string, nginxExecutable string, runner commandRunner) (*Controller, error) { for _, entry := range []struct { name string value string }{ {"host Nginx configuration", configPath}, {"Nginx executable", nginxExecutable}, } { if !filepath.IsAbs(entry.value) || filepath.Clean(entry.value) != entry.value { return nil, fmt.Errorf("%s must be a clean absolute path", entry.name) } } if runner == nil { return nil, errors.New("host Nginx command runner is required") } return &Controller{ configPath: configPath, nginxExecutable: nginxExecutable, runner: runner, }, nil } // Read 返回当前完整配置内容及其活动后端端口。它要求配置文件是直接存在的普通文件 // (非符号链接),读取后调用 ActiveBackendPort 解析活动端口。 func (c *Controller) Read() (Snapshot, error) { info, err := os.Lstat(c.configPath) if err != nil { return Snapshot{}, fmt.Errorf("inspect host Nginx configuration %s: %w", c.configPath, err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return Snapshot{}, fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath) } content, err := os.ReadFile(c.configPath) if err != nil { return Snapshot{}, fmt.Errorf("read host Nginx configuration %s: %w", c.configPath, err) } port, err := ActiveBackendPort(content) if err != nil { return Snapshot{}, err } return Snapshot{Content: content, ActivePort: port}, nil } // Switch 基于当前配置渲染出 activePort 为活动的配置,校验后原子替换并重载 Nginx。 // 若当前活动端口已等于 activePort 则直接返回当前快照,不做任何改动。 // 返回的 Snapshot 是切换前的完整快照,供调用方在后续失败时补偿。 func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, error) { previous, err := c.Read() if err != nil { return Snapshot{}, err } if previous.ActivePort == activePort { return previous, nil } next, err := RenderBackendPort(previous.Content, activePort) if err != nil { return Snapshot{}, err } if err := c.replaceValidateReload(ctx, next, previous.Content); err != nil { return Snapshot{}, err } return previous, nil } // Restore 原子恢复一份先前持久化的完整配置并重载 Nginx。它校验快照非空、 // 内容中的活动端口与快照元数据一致,且仅在与当前内容不同时才执行替换。 func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error { if len(snapshot.Content) == 0 { return errors.New("host Nginx restore snapshot is empty") } parsedPort, err := ActiveBackendPort(snapshot.Content) if err != nil { return fmt.Errorf("validate host Nginx restore snapshot: %w", err) } if parsedPort != snapshot.ActivePort { return fmt.Errorf("host Nginx restore snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort) } current, err := c.Read() if err != nil { return err } if string(current.Content) == string(snapshot.Content) { return nil } return c.replaceValidateReload(ctx, snapshot.Content, current.Content) } // Apply 安装一份先前持久化的完整配置快照并重载 Nginx。它校验快照非空、 // 内容中的活动端口与快照元数据一致后直接替换当前配置。 func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error { if len(snapshot.Content) == 0 { return errors.New("host Nginx apply snapshot is empty") } parsedPort, err := ActiveBackendPort(snapshot.Content) if err != nil { return fmt.Errorf("validate host Nginx apply snapshot: %w", err) } if parsedPort != snapshot.ActivePort { return fmt.Errorf("host Nginx apply snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort) } current, err := c.Read() if err != nil { return err } return c.replaceValidateReload(ctx, snapshot.Content, current.Content) } // replaceValidateReload 先原子写入 desired,再用 nginx -t 校验,最后通过 nginx -s reload // 平滑重载 Nginx。校验或重载失败时都会回滚到 rollback 内容并把补偿错误合并返回。 func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte, rollback []byte) error { if err := c.atomicWrite(desired); err != nil { return err } if err := c.runner.Run(ctx, c.nginxExecutable, "-t"); err != nil { return errors.Join( fmt.Errorf("validate host Nginx configuration: %w", err), c.restoreAfterFailure(ctx, rollback), ) } if err := c.runner.Run(ctx, c.nginxExecutable, "-s", "reload"); err != nil { return errors.Join( fmt.Errorf("reload host Nginx: %w", err), c.restoreAfterFailure(ctx, rollback), ) } return nil } // restoreAfterFailure 在失败后把配置回滚为 content,并再次校验与重载 Nginx, // 将校验与重载的错误合并返回。 func (c *Controller) restoreAfterFailure(ctx context.Context, content []byte) error { if err := c.atomicWrite(content); err != nil { return fmt.Errorf("restore host Nginx configuration after failure: %w", err) } validateErr := c.runner.Run(ctx, c.nginxExecutable, "-t") reloadErr := c.runner.Run(ctx, c.nginxExecutable, "-s", "reload") return errors.Join( wrapError("validate restored host Nginx configuration", validateErr), wrapError("reload restored host Nginx configuration", reloadErr), ) } // atomicWrite 通过同目录临时文件加 rename 的方式原子替换配置文件,并保留原文件权限、 // 同步临时文件与父目录,确保替换持久且不会留下半成品。 func (c *Controller) atomicWrite(content []byte) error { info, err := os.Lstat(c.configPath) if err != nil { return fmt.Errorf("inspect host Nginx configuration before replacement: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath) } parent := filepath.Dir(c.configPath) temporary, err := os.CreateTemp(parent, ".yms-daemon-nginx-*") if err != nil { return fmt.Errorf("create temporary host Nginx configuration: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(info.Mode().Perm()); err != nil { _ = temporary.Close() return fmt.Errorf("set temporary host Nginx configuration permissions: %w", err) } if _, err := temporary.Write(content); err != nil { _ = temporary.Close() return fmt.Errorf("write temporary host Nginx configuration: %w", err) } if err := temporary.Sync(); err != nil { _ = temporary.Close() return fmt.Errorf("flush temporary host Nginx configuration: %w", err) } if err := temporary.Close(); err != nil { return fmt.Errorf("close temporary host Nginx configuration: %w", err) } if err := os.Rename(temporaryPath, c.configPath); err != nil { return fmt.Errorf("replace host Nginx configuration: %w", err) } return syncDirectory(parent) } // commandRunner 抽象外部命令执行,便于在测试中注入记录型运行器。 type commandRunner interface { Run(context.Context, string, ...string) error } // execRunner commandRunner 的生产实现,通过 os/exec 执行真实外部命令。 type execRunner struct{} // Run 执行 executable 及其参数,失败时把命令的标准输出与错误输出附加到错误信息中。 func (execRunner) Run(ctx context.Context, executable string, arguments ...string) error { output, err := exec.CommandContext(ctx, executable, arguments...).CombinedOutput() if err == nil { return nil } detail := strings.TrimSpace(string(output)) if detail == "" { return err } return fmt.Errorf("%w: %s", err, detail) } // wrapError 在 err 非空时为其附加 message 前缀并返回,err 为空则返回 nil。 func wrapError(message string, err error) error { if err == nil { return nil } return fmt.Errorf("%s: %w", message, err) } // syncDirectory 打开 directory 指向的目录并调用 Sync 将其刷入磁盘, // 保证配置目录项变更持久化。 func syncDirectory(directory string) error { file, err := os.Open(directory) if err != nil { return fmt.Errorf("open host Nginx configuration directory: %w", err) } syncErr := file.Sync() closeErr := file.Close() if err := errors.Join(syncErr, closeErr); err != nil { return fmt.Errorf("flush host Nginx configuration directory: %w", err) } return nil }