refactor: use nginx -s reload instead of systemd
- doc: add comment
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// Package hostnginx manages the exact backend upstream block used by the current host Nginx deployment.
|
||||
// Package hostnginx 负责解析、渲染并校验当前宿主 Nginx 配置中被 daemon 管理的后端 upstream 块,
|
||||
// 保证 upstream 中的后端服务器格式与端口取值精确符合部署契约。
|
||||
package hostnginx
|
||||
|
||||
import (
|
||||
@@ -10,13 +11,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// managedBegin 受管理 upstream 块的起始标记行。
|
||||
managedBegin = "# yms-update managed upstream begin"
|
||||
managedEnd = "# yms-update managed upstream end"
|
||||
port8080 = 8080
|
||||
port8081 = 8081
|
||||
// managedEnd 受管理 upstream 块的结束标记行。
|
||||
managedEnd = "# yms-update managed upstream end"
|
||||
// port8080 蓝绿发布中 8080 槽位的端口号。
|
||||
port8080 = 8080
|
||||
// port8081 蓝绿发布中 8081 槽位的端口号。
|
||||
port8081 = 8081
|
||||
)
|
||||
|
||||
// ActiveBackendPort reads the one uncommented backend server in the managed upstream block.
|
||||
// ActiveBackendPort 从 content 的受管理 upstream 块中读取唯一一条未注释的后端服务器端口。
|
||||
// 若不存在活动服务器、存在多个活动服务器或块格式非法,均返回错误。
|
||||
func ActiveBackendPort(content []byte) (int, error) {
|
||||
block, err := parseManagedBlock(content)
|
||||
if err != nil {
|
||||
@@ -37,7 +43,8 @@ func ActiveBackendPort(content []byte) (int, error) {
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// RenderBackendPort returns a complete configuration with only activePort uncommented.
|
||||
// RenderBackendPort 返回一份仅 activePort 未注释的完整配置,其余后端服务器行均被注释。
|
||||
// activePort 只允许 8080 或 8081,渲染结果会再经 ActiveBackendPort 校验后才返回。
|
||||
func RenderBackendPort(content []byte, activePort int) ([]byte, error) {
|
||||
if activePort != port8080 && activePort != port8081 {
|
||||
return nil, fmt.Errorf("host Nginx backend port must be 8080 or 8081: %d", activePort)
|
||||
@@ -65,17 +72,27 @@ func RenderBackendPort(content []byte, activePort int) ([]byte, error) {
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
// managedBlock 受管理 upstream 块的解析结果,保留原始行供渲染时整体重建。
|
||||
type managedBlock struct {
|
||||
lines []string
|
||||
// lines 配置文件的全部行,渲染时按行号直接改写并重新拼接。
|
||||
lines []string
|
||||
// servers 块内解析出的后端服务器,按出现顺序排列。
|
||||
servers []managedServer
|
||||
}
|
||||
|
||||
// managedServer 受管理 upstream 块内一条后端服务器行的解析结果。
|
||||
type managedServer struct {
|
||||
line int
|
||||
port int
|
||||
// line 服务器行在 lines 中的下标。
|
||||
line int
|
||||
// port 服务器地址中的端口号,只允许 8080 或 8081。
|
||||
port int
|
||||
// commented 为 true 表示该行以 "# server " 开头(当前未启用)。
|
||||
commented bool
|
||||
}
|
||||
|
||||
// parseManagedBlock 解析 content 中由 managedBegin 与 managedEnd 标记的受管理 upstream 块。
|
||||
// 它要求存在且仅存在一对有序标记、块内恰好包含 8080 与 8081 两条后端服务器且无多余非空行,
|
||||
// 任一约束不满足即返回错误。
|
||||
func parseManagedBlock(content []byte) (managedBlock, error) {
|
||||
if len(content) == 0 {
|
||||
return managedBlock{}, errors.New("host Nginx configuration is empty")
|
||||
@@ -131,6 +148,9 @@ func parseManagedBlock(content []byte) (managedBlock, error) {
|
||||
return managedBlock{lines: lines, servers: servers}, nil
|
||||
}
|
||||
|
||||
// parseManagedServer 解析单行 line 是否为后端服务器行,返回解析结果与 found。
|
||||
// found 为 false 表示该行既非注释服务器也非活动服务器(如空行或其他行),由调用方继续处理;
|
||||
// 若该行格式或端口不符合契约则返回错误。
|
||||
func parseManagedServer(line string, lineIndex int) (managedServer, bool, error) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
commented := strings.HasPrefix(trimmed, "# server ")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serverConfiguration 含受管理 upstream 块、8080 被注释、8081 活动的示例配置,供各测试共享。
|
||||
const serverConfiguration = `http {
|
||||
upstream yms-server {
|
||||
# yms-update managed upstream begin
|
||||
@@ -16,6 +17,8 @@ const serverConfiguration = `http {
|
||||
}
|
||||
`
|
||||
|
||||
// TestActiveBackendPortReadsExactManagedBlock 验证 ActiveBackendPort 能从示例配置中
|
||||
// 精确读取到活动端口 8081。
|
||||
func TestActiveBackendPortReadsExactManagedBlock(t *testing.T) {
|
||||
port, err := ActiveBackendPort([]byte(serverConfiguration))
|
||||
if err != nil || port != 8081 {
|
||||
@@ -23,6 +26,8 @@ func TestActiveBackendPortReadsExactManagedBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer 验证 RenderBackendPort
|
||||
// 只切换一条服务器行,其余配置保持不变,且渲染结果的活动端口正确。
|
||||
func TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer(t *testing.T) {
|
||||
rendered, err := RenderBackendPort([]byte(serverConfiguration), 8080)
|
||||
if err != nil {
|
||||
@@ -38,6 +43,8 @@ func TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagedBlockRejectsAmbiguousOrAlteredInput 验证受管理块在端口双活、端口缺失、
|
||||
// 选项被篡改或出现多余行等情况下都会被拒绝。
|
||||
func TestManagedBlockRejectsAmbiguousOrAlteredInput(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"both active": strings.Replace(serverConfiguration, "# server 10.11.1.117:8080", "server 10.11.1.117:8080", 1),
|
||||
|
||||
@@ -10,55 +10,55 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Snapshot is the complete host Nginx configuration before or after a switch.
|
||||
// Snapshot 切换前后完整宿主 Nginx 配置的快照,用于恢复与补偿。
|
||||
type Snapshot struct {
|
||||
Content []byte
|
||||
// Content 配置文件的完整字节内容。
|
||||
Content []byte
|
||||
// ActivePort 内容中处于活动状态的后端端口。
|
||||
ActivePort int
|
||||
}
|
||||
|
||||
// Controller validates, atomically replaces, and reloads the current host Nginx configuration.
|
||||
// Controller 负责校验、原子替换并重载当前宿主 Nginx 配置。
|
||||
type Controller struct {
|
||||
configPath string
|
||||
nginxExecutable string
|
||||
systemctlPath string
|
||||
nginxServiceName string
|
||||
runner commandRunner
|
||||
// configPath 宿主 Nginx 配置文件的绝对路径。
|
||||
configPath string
|
||||
// nginxExecutable 用于配置校验的 Nginx 可执行文件绝对路径。
|
||||
nginxExecutable string
|
||||
// runner 执行外部命令的抽象,便于测试时替换。
|
||||
runner commandRunner
|
||||
}
|
||||
|
||||
// NewController requires every external identifier to be supplied explicitly.
|
||||
func NewController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string) (*Controller, error) {
|
||||
return newController(configPath, nginxExecutable, systemctlPath, nginxServiceName, execRunner{})
|
||||
// NewController 要求显式提供配置文件和 Nginx 可执行文件路径。
|
||||
func NewController(configPath string, nginxExecutable string) (*Controller, error) {
|
||||
return newController(configPath, nginxExecutable, execRunner{})
|
||||
}
|
||||
|
||||
func newController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string, runner commandRunner) (*Controller, error) {
|
||||
// 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},
|
||||
{"systemctl executable", systemctlPath},
|
||||
} {
|
||||
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 nginxServiceName == "" || strings.TrimSpace(nginxServiceName) != nginxServiceName {
|
||||
return nil, errors.New("exact Nginx systemd service name is required")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("host Nginx command runner is required")
|
||||
}
|
||||
return &Controller{
|
||||
configPath: configPath,
|
||||
nginxExecutable: nginxExecutable,
|
||||
systemctlPath: systemctlPath,
|
||||
nginxServiceName: nginxServiceName,
|
||||
runner: runner,
|
||||
configPath: configPath,
|
||||
nginxExecutable: nginxExecutable,
|
||||
runner: runner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Read returns the complete current configuration and its active backend port.
|
||||
// Read 返回当前完整配置内容及其活动后端端口。它要求配置文件是直接存在的普通文件
|
||||
// (非符号链接),读取后调用 ActiveBackendPort 解析活动端口。
|
||||
func (c *Controller) Read() (Snapshot, error) {
|
||||
info, err := os.Lstat(c.configPath)
|
||||
if err != nil {
|
||||
@@ -78,8 +78,9 @@ func (c *Controller) Read() (Snapshot, error) {
|
||||
return Snapshot{Content: content, ActivePort: port}, nil
|
||||
}
|
||||
|
||||
// Switch renders activePort from the current configuration, then validates and reloads Nginx.
|
||||
// It returns the durable pre-switch snapshot required for compensation.
|
||||
// Switch 基于当前配置渲染出 activePort 为活动的配置,校验后原子替换并重载 Nginx。
|
||||
// 若当前活动端口已等于 activePort 则直接返回当前快照,不做任何改动。
|
||||
// 返回的 Snapshot 是切换前的完整快照,供调用方在后续失败时补偿。
|
||||
func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, error) {
|
||||
previous, err := c.Read()
|
||||
if err != nil {
|
||||
@@ -98,7 +99,8 @@ func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, erro
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
// Restore atomically restores a previously persisted complete configuration and reloads Nginx.
|
||||
// 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")
|
||||
@@ -120,7 +122,8 @@ func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error {
|
||||
return c.replaceValidateReload(ctx, snapshot.Content, current.Content)
|
||||
}
|
||||
|
||||
// Apply installs one previously persisted complete snapshot and reloads Nginx.
|
||||
// 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")
|
||||
@@ -139,6 +142,8 @@ func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error {
|
||||
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
|
||||
@@ -149,27 +154,31 @@ func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte,
|
||||
c.restoreAfterFailure(ctx, rollback),
|
||||
)
|
||||
}
|
||||
if err := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName); err != nil {
|
||||
if err := c.runner.Run(ctx, c.nginxExecutable, "-s", "reload"); err != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("reload host Nginx service %s: %w", c.nginxServiceName, err),
|
||||
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.systemctlPath, "reload", "--", c.nginxServiceName)
|
||||
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 {
|
||||
@@ -206,12 +215,15 @@ func (c *Controller) atomicWrite(content []byte) error {
|
||||
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 {
|
||||
@@ -224,6 +236,7 @@ func (execRunner) Run(ctx context.Context, executable string, arguments ...strin
|
||||
return fmt.Errorf("%w: %s", err, detail)
|
||||
}
|
||||
|
||||
// wrapError 在 err 非空时为其附加 message 前缀并返回,err 为空则返回 nil。
|
||||
func wrapError(message string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -231,6 +244,8 @@ func wrapError(message string, err error) error {
|
||||
return fmt.Errorf("%s: %w", message, err)
|
||||
}
|
||||
|
||||
// syncDirectory 打开 directory 指向的目录并调用 Sync 将其刷入磁盘,
|
||||
// 保证配置目录项变更持久化。
|
||||
func syncDirectory(directory string) error {
|
||||
file, err := os.Open(directory)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControllerSwitchesAndReloadsExactService(t *testing.T) {
|
||||
// TestControllerSwitchesAndReloadsNginx 验证 Switch 能将活动端口切到 8080,
|
||||
// 且实际执行了 nginx -t 与 nginx -s reload 命令。
|
||||
func TestControllerSwitchesAndReloadsNginx(t *testing.T) {
|
||||
configPath := writeNginxConfig(t, serverConfiguration)
|
||||
runner := &recordingRunner{}
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner)
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", runner)
|
||||
if err != nil {
|
||||
t.Fatalf("create host Nginx controller: %v", err)
|
||||
}
|
||||
@@ -30,17 +32,19 @@ func TestControllerSwitchesAndReloadsExactService(t *testing.T) {
|
||||
}
|
||||
wantCalls := [][]string{
|
||||
{"/usr/sbin/nginx", "-t"},
|
||||
{"/bin/systemctl", "reload", "--", "nginx.service"},
|
||||
{"/usr/sbin/nginx", "-s", "reload"},
|
||||
}
|
||||
if !slices.EqualFunc(runner.calls, wantCalls, slices.Equal) {
|
||||
t.Fatalf("unexpected host Nginx commands: %+v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControllerRestoresConfigurationWhenValidationFails 验证 nginx -t 校验失败时 Switch 返回错误,
|
||||
// 并把配置回滚为原始内容,同时执行了校验、回滚校验与回滚重载三次命令。
|
||||
func TestControllerRestoresConfigurationWhenValidationFails(t *testing.T) {
|
||||
configPath := writeNginxConfig(t, serverConfiguration)
|
||||
runner := &recordingRunner{errors: []error{errors.New("nginx test failed"), nil, nil}}
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner)
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", runner)
|
||||
if err != nil {
|
||||
t.Fatalf("create host Nginx controller: %v", err)
|
||||
}
|
||||
@@ -57,10 +61,12 @@ func TestControllerRestoresConfigurationWhenValidationFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestControllerRestoreUsesCompleteSnapshot 验证 Restore 能使用 Switch 返回的完整快照
|
||||
// 把配置恢复到切换前的原始内容与活动端口。
|
||||
func TestControllerRestoreUsesCompleteSnapshot(t *testing.T) {
|
||||
configPath := writeNginxConfig(t, serverConfiguration)
|
||||
runner := &recordingRunner{}
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner)
|
||||
controller, err := newController(configPath, "/usr/sbin/nginx", runner)
|
||||
if err != nil {
|
||||
t.Fatalf("create host Nginx controller: %v", err)
|
||||
}
|
||||
@@ -77,6 +83,8 @@ func TestControllerRestoreUsesCompleteSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeNginxConfig 将 content 写入临时目录下的 nginx.conf 并返回其绝对路径,
|
||||
// 供 Controller 测试使用。写入失败会直接终止测试。
|
||||
func writeNginxConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
@@ -86,11 +94,15 @@ func writeNginxConfig(t *testing.T, content string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// recordingRunner commandRunner 的测试实现,记录每次调用并依次返回预设的错误。
|
||||
type recordingRunner struct {
|
||||
calls [][]string
|
||||
// calls 记录每次执行的命令(含参数)序列。
|
||||
calls [][]string
|
||||
// errors 依次返回的预设错误,取完后续调用返回 nil。
|
||||
errors []error
|
||||
}
|
||||
|
||||
// Run 记录本次调用命令,并按 errors 中的顺序返回下一个预设错误。
|
||||
func (r *recordingRunner) Run(_ context.Context, executable string, arguments ...string) error {
|
||||
call := append([]string{executable}, arguments...)
|
||||
r.calls = append(r.calls, call)
|
||||
|
||||
Reference in New Issue
Block a user