From c4f314f2c58bd68e76afffccea4c2658103e1f72 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 9 Mar 2026 23:35:34 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20Nginx=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87=20Docker?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8=E7=8B=AC=E7=AB=8B=20Nginx=20=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=AE=8C=E5=96=84=E7=9B=B8=E5=85=B3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=92=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_agent/agent.json | 14 +++ atsf_agent/cmd/agent/main.go | 10 +- atsf_agent/internal/config/config.go | 37 +++--- atsf_agent/internal/nginx/manager.go | 109 ++++++++++++++++-- atsf_agent/internal/nginx/manager_test.go | 114 +++++++++++++++++++ atsf_agent/var/lib/atsflare/agent-state.json | 6 + docs/deployment.md | 15 ++- docs/design.md | 5 +- docs/development-guidelines.md | 7 +- docs/development-plan.md | 1 + 10 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 atsf_agent/agent.json create mode 100644 atsf_agent/internal/nginx/manager_test.go create mode 100644 atsf_agent/var/lib/atsflare/agent-state.json diff --git a/atsf_agent/agent.json b/atsf_agent/agent.json new file mode 100644 index 00000000..20f72bef --- /dev/null +++ b/atsf_agent/agent.json @@ -0,0 +1,14 @@ +{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "123", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "agent_version": "0.1.0", + "nginx_version": "1.25.5", + "route_config_path": "./etc/nginx/conf.d/atsflare_routes.conf", + "state_path": "./var/lib/atsflare/agent-state.json", + "nginx_binary": "nginx", + "heartbeat_interval": 30000000000, + "sync_interval": 30000000000, + "request_timeout": 10000000000 +} \ No newline at end of file diff --git a/atsf_agent/cmd/agent/main.go b/atsf_agent/cmd/agent/main.go index ef038176..bb36e2fe 100644 --- a/atsf_agent/cmd/agent/main.go +++ b/atsf_agent/cmd/agent/main.go @@ -33,9 +33,13 @@ func main() { HeartbeatService: heartbeat.New(client), SyncService: syncservice.New(client, &nginx.Manager{ RouteConfigPath: cfg.RouteConfigPath, - Executor: &nginx.ShellExecutor{ - Binary: cfg.NginxBinary, - }, + Executor: nginx.NewExecutor(nginx.ExecutorOptions{ + NginxPath: cfg.NginxPath, + DockerBinary: cfg.DockerBinary, + ContainerName: cfg.NginxContainerName, + Image: cfg.NginxDockerImage, + RouteConfigPath: cfg.RouteConfigPath, + }), }, stateStore), } diff --git a/atsf_agent/internal/config/config.go b/atsf_agent/internal/config/config.go index 3eecd3ec..94a7b782 100644 --- a/atsf_agent/internal/config/config.go +++ b/atsf_agent/internal/config/config.go @@ -9,18 +9,21 @@ import ( ) type Config struct { - ServerURL string `json:"server_url"` - AgentToken string `json:"agent_token"` - NodeName string `json:"node_name"` - NodeIP string `json:"node_ip"` - AgentVersion string `json:"agent_version"` - NginxVersion string `json:"nginx_version"` - RouteConfigPath string `json:"route_config_path"` - StatePath string `json:"state_path"` - NginxBinary string `json:"nginx_binary"` - HeartbeatInterval time.Duration `json:"heartbeat_interval"` - SyncInterval time.Duration `json:"sync_interval"` - RequestTimeout time.Duration `json:"request_timeout"` + ServerURL string `json:"server_url"` + AgentToken string `json:"agent_token"` + NodeName string `json:"node_name"` + NodeIP string `json:"node_ip"` + AgentVersion string `json:"agent_version"` + NginxVersion string `json:"nginx_version"` + NginxPath string `json:"nginx_path"` + NginxContainerName string `json:"nginx_container_name"` + NginxDockerImage string `json:"nginx_docker_image"` + DockerBinary string `json:"docker_binary"` + RouteConfigPath string `json:"route_config_path"` + StatePath string `json:"state_path"` + HeartbeatInterval time.Duration `json:"heartbeat_interval"` + SyncInterval time.Duration `json:"sync_interval"` + RequestTimeout time.Duration `json:"request_timeout"` } func Load(path string) (*Config, error) { @@ -49,8 +52,14 @@ func applyDefaults(cfg *Config) { if cfg.StatePath == "" { cfg.StatePath = filepath.Clean("./atsf_agent_state.json") } - if cfg.NginxBinary == "" { - cfg.NginxBinary = "nginx" + if cfg.NginxContainerName == "" { + cfg.NginxContainerName = "atsflare-nginx" + } + if cfg.NginxDockerImage == "" { + cfg.NginxDockerImage = "nginx:stable-alpine" + } + if cfg.DockerBinary == "" { + cfg.DockerBinary = "docker" } if cfg.HeartbeatInterval <= 0 { cfg.HeartbeatInterval = 30 * time.Second diff --git a/atsf_agent/internal/nginx/manager.go b/atsf_agent/internal/nginx/manager.go index c53254c0..9b4a79a9 100644 --- a/atsf_agent/internal/nginx/manager.go +++ b/atsf_agent/internal/nginx/manager.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" ) type Executor interface { @@ -14,28 +15,97 @@ type Executor interface { Reload(ctx context.Context) error } -type ShellExecutor struct { - Binary string +type CommandRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) } -func (e *ShellExecutor) Test(ctx context.Context) error { - cmd := exec.CommandContext(ctx, e.Binary, "-t") +type OSCommandRunner struct{} + +func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.CombinedOutput() + return output, err +} + +type PathExecutor struct { + Path string + Runner CommandRunner +} + +func (e *PathExecutor) Test(ctx context.Context) error { + output, err := e.Runner.Run(ctx, e.Path, "-t") if err != nil { return fmt.Errorf("nginx -t failed: %w: %s", err, string(output)) } return nil } -func (e *ShellExecutor) Reload(ctx context.Context) error { - cmd := exec.CommandContext(ctx, e.Binary, "-s", "reload") - output, err := cmd.CombinedOutput() +func (e *PathExecutor) Reload(ctx context.Context) error { + output, err := e.Runner.Run(ctx, e.Path, "-s", "reload") if err != nil { return fmt.Errorf("nginx reload failed: %w: %s", err, string(output)) } return nil } +type DockerExecutor struct { + DockerBinary string + ContainerName string + Image string + RouteConfigDir string + Runner CommandRunner +} + +func (e *DockerExecutor) Test(ctx context.Context) error { + if err := e.ensureContainer(ctx); err != nil { + return err + } + output, err := e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, "nginx", "-t") + if err != nil { + return fmt.Errorf("docker nginx -t failed: %w: %s", err, string(output)) + } + return nil +} + +func (e *DockerExecutor) Reload(ctx context.Context) error { + if err := e.ensureContainer(ctx); err != nil { + return err + } + output, err := e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, "nginx", "-s", "reload") + if err != nil { + return fmt.Errorf("docker nginx reload failed: %w: %s", err, string(output)) + } + return nil +} + +func (e *DockerExecutor) ensureContainer(ctx context.Context) error { + output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName) + if err == nil { + if strings.TrimSpace(string(output)) == "true" { + return nil + } + startOutput, startErr := e.Runner.Run(ctx, e.DockerBinary, "start", e.ContainerName) + if startErr != nil { + return fmt.Errorf("docker start nginx failed: %w: %s", startErr, string(startOutput)) + } + return nil + } + + runArgs := []string{ + "run", "-d", + "--name", e.ContainerName, + "-p", "80:80", + "-p", "443:443", + "-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir), + e.Image, + } + runOutput, runErr := e.Runner.Run(ctx, e.DockerBinary, runArgs...) + if runErr != nil { + return fmt.Errorf("docker run nginx failed: %w: %s", runErr, string(runOutput)) + } + return nil +} + type Manager struct { RouteConfigPath string Executor Executor @@ -63,6 +133,31 @@ func (m *Manager) Apply(ctx context.Context, content string) error { return nil } +type ExecutorOptions struct { + NginxPath string + DockerBinary string + ContainerName string + Image string + RouteConfigPath string +} + +func NewExecutor(options ExecutorOptions) Executor { + runner := &OSCommandRunner{} + if options.NginxPath != "" { + return &PathExecutor{ + Path: options.NginxPath, + Runner: runner, + } + } + return &DockerExecutor{ + DockerBinary: options.DockerBinary, + ContainerName: options.ContainerName, + Image: options.Image, + RouteConfigDir: filepath.Dir(options.RouteConfigPath), + Runner: runner, + } +} + func (m *Manager) backup() (string, bool, error) { if m.RouteConfigPath == "" { return "", false, errors.New("route config path 不能为空") diff --git a/atsf_agent/internal/nginx/manager_test.go b/atsf_agent/internal/nginx/manager_test.go new file mode 100644 index 00000000..6f6cd0c2 --- /dev/null +++ b/atsf_agent/internal/nginx/manager_test.go @@ -0,0 +1,114 @@ +package nginx + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "testing" +) + +type runCall struct { + name string + args []string +} + +type fakeRunner struct { + calls []runCall + runFn func(name string, args ...string) ([]byte, error) +} + +func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)}) + if r.runFn != nil { + return r.runFn(name, args...) + } + return nil, nil +} + +func TestPathExecutorCommands(t *testing.T) { + runner := &fakeRunner{} + executor := &PathExecutor{ + Path: "/opt/nginx/sbin/nginx", + Runner: runner, + } + + if err := executor.Test(context.Background()); err != nil { + t.Fatalf("Test failed: %v", err) + } + if err := executor.Reload(context.Background()); err != nil { + t.Fatalf("Reload failed: %v", err) + } + + expected := []runCall{ + {name: "/opt/nginx/sbin/nginx", args: []string{"-t"}}, + {name: "/opt/nginx/sbin/nginx", args: []string{"-s", "reload"}}, + } + if !reflect.DeepEqual(runner.calls, expected) { + t.Fatalf("unexpected calls: %#v", runner.calls) + } +} + +func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) { + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) >= 1 && args[0] == "inspect" { + return []byte(""), errors.New("not found") + } + return []byte("ok"), nil + }, + } + executor := &DockerExecutor{ + DockerBinary: "docker", + ContainerName: "atsflare-nginx", + Image: "nginx:stable-alpine", + RouteConfigDir: filepath.Clean("/tmp/routes"), + Runner: runner, + } + + if err := executor.Test(context.Background()); err != nil { + t.Fatalf("Test failed: %v", err) + } + + if len(runner.calls) != 3 { + t.Fatalf("expected 3 calls, got %d", len(runner.calls)) + } + if runner.calls[1].args[0] != "run" { + t.Fatalf("expected docker run on second call, got %#v", runner.calls[1]) + } + if runner.calls[2].args[0] != "exec" { + t.Fatalf("expected docker exec on third call, got %#v", runner.calls[2]) + } +} + +func TestDockerExecutorStartsStoppedContainer(t *testing.T) { + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) >= 2 && args[0] == "inspect" { + return []byte("false"), nil + } + return []byte("ok"), nil + }, + } + executor := &DockerExecutor{ + DockerBinary: "docker", + ContainerName: "atsflare-nginx", + Image: "nginx:stable-alpine", + RouteConfigDir: filepath.Clean("/tmp/routes"), + Runner: runner, + } + + if err := executor.Reload(context.Background()); err != nil { + t.Fatalf("Reload failed: %v", err) + } + + if len(runner.calls) != 3 { + t.Fatalf("expected 3 calls, got %d", len(runner.calls)) + } + if runner.calls[1].args[0] != "start" { + t.Fatalf("expected docker start on second call, got %#v", runner.calls[1]) + } + if runner.calls[2].args[0] != "exec" { + t.Fatalf("expected docker exec on third call, got %#v", runner.calls[2]) + } +} diff --git a/atsf_agent/var/lib/atsflare/agent-state.json b/atsf_agent/var/lib/atsflare/agent-state.json new file mode 100644 index 00000000..777190e7 --- /dev/null +++ b/atsf_agent/var/lib/atsflare/agent-state.json @@ -0,0 +1,6 @@ +{ + "node_id": "node-272a017338e3b01c", + "current_version": "", + "current_checksum": "", + "last_error": "nginx -t failed: exec: \"nginx\": executable file not found in $PATH: " +} \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index e92fb24c..99a16ab8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -70,9 +70,11 @@ go run . "node_ip": "10.0.0.8", "agent_version": "0.1.0", "nginx_version": "1.25.5", + "nginx_path": "/opt/nginx/sbin/nginx", + "nginx_container_name": "atsflare-nginx", + "nginx_docker_image": "nginx:stable-alpine", "route_config_path": "/etc/nginx/conf.d/atsflare_routes.conf", "state_path": "/var/lib/atsflare/agent-state.json", - "nginx_binary": "nginx", "heartbeat_interval": 30000000000, "sync_interval": 30000000000, "request_timeout": 10000000000 @@ -84,6 +86,9 @@ go run . - 时间字段单位是纳秒,因为当前实现直接使用 Go 的 `time.Duration` JSON 反序列化 - `agent_token` 必须与 Server 侧 `AGENT_TOKEN` 完全一致 - `route_config_path` 只应指向 ATSFlare 独立管理的路由文件 +- `nginx_path` 用于显式指定独立 Nginx 可执行文件路径 +- 如果未指定 `nginx_path`,Agent 会尝试通过 Docker 启动独立 Nginx 容器 +- Docker 模式默认使用 `nginx_container_name` 和 `nginx_docker_image` ### 3.2 启动 Agent @@ -126,9 +131,10 @@ go build -o atsflare-agent ./cmd/agent 1. Agent 首次注册节点 2. Agent 拉取当前激活版本 3. Agent 写入 `route_config_path` -4. Agent 执行 `nginx -t` -5. Agent 执行 `nginx -s reload` -6. Agent 上报成功结果 +4. Agent 使用 `nginx_path` 指向的独立 Nginx,或自动准备 Docker Nginx 容器 +5. Agent 执行 `nginx -t` +6. Agent 执行 `nginx -s reload` +7. Agent 上报成功结果 ### 4.4 验证管理端状态 @@ -181,6 +187,7 @@ npm run build - Agent 运行器当前任一心跳或同步失败会直接退出,需要结合进程管理器拉起 - 尚未提供 systemd unit 文件 - 尚未提供 Docker Compose 或一键部署脚本 +- Docker 模式当前默认直接启动单容器 Nginx,挂载与端口策略仍是 MVP 水平 - 前端页面已可用,但交互和校验仍是 MVP 水平 - 目前联调说明以手工步骤为主,未内置完整自动化端到端脚本 diff --git a/docs/design.md b/docs/design.md index 1d49e975..cd814db2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -62,7 +62,9 @@ Agent 使用 Go 单体程序: * 单二进制 * systemd 管理 -* 本地调用 `nginx` +* 优先调用独立 Nginx,而不是依赖系统全局 Nginx +* 显式配置 `nginx_path` 时,直接调用该路径下的 Nginx +* 未配置 `nginx_path` 时,默认通过 Docker 运行独立 Nginx 容器 * 管理本机 Nginx 路由配置文件和 reload ### Nginx 管理边界 @@ -71,6 +73,7 @@ Agent 使用 Go 单体程序: * 重点生成独立的 Nginx 路由配置文件,例如 `/etc/nginx/conf.d/atsflare_routes.conf` * `nginx.conf`、TLS 证书、缓存细节、upstream 高级配置先保持节点本地静态配置 +* Agent 可以管理独立安装路径下的 Nginx,或者独立 Docker Nginx 容器 也就是说,MVP 先把 Nginx 当成“可集中配置的反向代理”,不是完整网关平台。 diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index d3ad9036..dd0acb99 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -47,7 +47,9 @@ Agent 放在 `atsf_agent`,使用 Go 单体程序开发。 * 单二进制 * systemd 运行 -* 本地调用 `nginx` +* 优先调用独立 Nginx,不依赖系统全局 Nginx +* 支持通过 `nginx_path` 显式指定独立 Nginx 可执行文件 +* 未指定 `nginx_path` 时,默认通过 Docker 启动独立 Nginx 容器 * 负责本机 Nginx 路由配置写入、校验、reload、状态上报 ### 2.3 Nginx 配置边界 @@ -88,6 +90,7 @@ Agent 放在 `atsf_agent`,使用 Go 单体程序开发。 * 调用 `nginx -t` 和 `nginx -s reload` * 失败回滚 * 上报应用结果 +* 管理独立 Nginx 路径或 Docker Nginx 容器 ### 3.3 `docs` @@ -260,6 +263,8 @@ Agent 必须满足以下行为: * 校验通过后执行 `nginx -s reload` * 失败时回滚备份并再次校验和 reload * 上报最终应用结果 +* 优先使用 `nginx_path` +* 未配置 `nginx_path` 时自动准备并使用 Docker Nginx 容器 ### 7.3 容错规范 diff --git a/docs/development-plan.md b/docs/development-plan.md index 138124b2..424d05a3 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -150,6 +150,7 @@ * 写入前会备份旧路由配置文件 * `nginx -t` 或 reload 失败后会回滚 * 回滚结果会回传给 Server +* Agent 可使用独立 Nginx 路径或 Docker Nginx 容器运行 ### Phase 4 检查项