feat: 更新配置,优化时间字段为毫秒,添加 Nginx 版本检测功能

This commit is contained in:
ryan
2026-03-10 19:58:43 +08:00
parent 8aab2b1ba0
commit 65a7331ea9
15 changed files with 334 additions and 109 deletions
+3 -7
View File
@@ -1,14 +1,10 @@
{
"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",
"data_dir": "./data",
"nginx_container_name": "atsflare-nginx",
"nginx_docker_image": "nginx:stable-alpine",
"heartbeat_interval": 30000000000,
"sync_interval": 30000000000,
"request_timeout": 10000000000
"heartbeat_interval": 30000,
"sync_interval": 30000,
"request_timeout": 10000
}
+13 -1
View File
@@ -24,9 +24,21 @@ func main() {
if err != nil {
log.Fatal(err)
}
cfg.NginxVersion = nginx.DetectVersion(
context.Background(),
nginx.ExecutorOptions{
NginxPath: cfg.NginxPath,
DockerBinary: cfg.DockerBinary,
ContainerName: cfg.NginxContainerName,
Image: cfg.NginxDockerImage,
RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir,
NginxCertDir: cfg.NginxCertDir,
},
)
log.Printf("agent config loaded: server=%s node=%s ip=%s heartbeat_interval=%s sync_interval=%s route_config=%s cert_dir=%s", cfg.ServerURL, cfg.NodeName, cfg.NodeIP, cfg.HeartbeatInterval, cfg.SyncInterval, cfg.RouteConfigPath, cfg.CertDir)
client := httpclient.New(cfg.ServerURL, cfg.AgentToken, cfg.RequestTimeout)
client := httpclient.New(cfg.ServerURL, cfg.AgentToken, cfg.RequestTimeout.Duration())
stateStore := state.NewStore(cfg.StatePath)
runner := &agent.Runner{
Config: cfg,
+2 -2
View File
@@ -52,9 +52,9 @@ func (r *Runner) Run(ctx context.Context) error {
log.Printf("agent initial discovery register failed: %v", err)
}
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval)
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
defer heartbeatTicker.Stop()
syncTicker := time.NewTicker(r.Config.SyncInterval)
syncTicker := time.NewTicker(r.Config.SyncInterval.Duration())
defer syncTicker.Stop()
for {
+12 -12
View File
@@ -102,10 +102,10 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
AgentToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: "0.1.0",
AgentVersion: config.AgentVersion,
NginxVersion: "1.25.5",
HeartbeatInterval: 10 * time.Millisecond,
SyncInterval: 20 * time.Millisecond,
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
SyncInterval: config.MillisecondDuration(20 * time.Millisecond),
},
StateStore: stateStore,
HeartbeatService: heartbeatService,
@@ -153,10 +153,10 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
AgentToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: "0.1.0",
AgentVersion: config.AgentVersion,
NginxVersion: "1.25.5",
HeartbeatInterval: 10 * time.Millisecond,
SyncInterval: 10 * time.Millisecond,
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
SyncInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
HeartbeatService: heartbeatService,
@@ -214,20 +214,20 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
DiscoveryToken: cfg.DiscoveryToken,
NodeName: cfg.NodeName,
NodeIP: cfg.NodeIP,
AgentVersion: "0.1.0",
AgentVersion: config.AgentVersion,
NginxVersion: "1.25.5",
HeartbeatInterval: 10 * time.Millisecond,
SyncInterval: 20 * time.Millisecond,
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
SyncInterval: config.MillisecondDuration(20 * time.Millisecond),
},
StateStore: stateStore,
HeartbeatService: heartbeatService,
SyncService: syncService,
}
runner.Config = cfg
runner.Config.AgentVersion = "0.1.0"
runner.Config.AgentVersion = config.AgentVersion
runner.Config.NginxVersion = "1.25.5"
runner.Config.HeartbeatInterval = 10 * time.Millisecond
runner.Config.SyncInterval = 20 * time.Millisecond
runner.Config.HeartbeatInterval = config.MillisecondDuration(10 * time.Millisecond)
runner.Config.SyncInterval = config.MillisecondDuration(20 * time.Millisecond)
err = runner.Run(ctx)
if !errors.Is(err, context.Canceled) {
+23 -25
View File
@@ -19,25 +19,25 @@ const (
)
type Config struct {
ServerURL string `json:"server_url"`
AgentToken string `json:"agent_token"`
DiscoveryToken string `json:"discovery_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"`
DataDir string `json:"data_dir"`
RouteConfigPath string `json:"route_config_path"`
CertDir string `json:"cert_dir"`
NginxCertDir string `json:"nginx_cert_dir"`
StatePath string `json:"state_path"`
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"`
DiscoveryToken string `json:"discovery_token"`
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
AgentVersion string `json:"-"`
NginxVersion string `json:"-"`
NginxPath string `json:"nginx_path"`
NginxContainerName string `json:"nginx_container_name"`
NginxDockerImage string `json:"nginx_docker_image"`
DockerBinary string `json:"docker_binary"`
DataDir string `json:"data_dir"`
RouteConfigPath string `json:"route_config_path"`
CertDir string `json:"cert_dir"`
NginxCertDir string `json:"nginx_cert_dir"`
StatePath string `json:"state_path"`
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
SyncInterval MillisecondDuration `json:"sync_interval"`
RequestTimeout MillisecondDuration `json:"request_timeout"`
configPath string
}
@@ -60,9 +60,7 @@ func Load(path string) (*Config, error) {
func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
if cfg.AgentVersion == "" {
cfg.AgentVersion = "dev"
}
cfg.AgentVersion = AgentVersion
if cfg.NginxContainerName == "" {
cfg.NginxContainerName = "atsflare-nginx"
}
@@ -103,13 +101,13 @@ func applyDefaults(cfg *Config, baseDir string) {
}
}
if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = 30 * time.Second
cfg.HeartbeatInterval = MillisecondDuration(30 * time.Second)
}
if cfg.SyncInterval <= 0 {
cfg.SyncInterval = 30 * time.Second
cfg.SyncInterval = MillisecondDuration(30 * time.Second)
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 10 * time.Second
cfg.RequestTimeout = MillisecondDuration(10 * time.Second)
}
normalizeManagedPaths(cfg)
}
+91 -11
View File
@@ -5,17 +5,17 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"agent_version": "0.1.0",
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
}
data, err := json.Marshal(payload)
if err != nil {
@@ -87,12 +87,11 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"data_dir": "/srv/atsflare",
"agent_version": "0.1.0",
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"data_dir": "/srv/atsflare",
}
data, err := json.Marshal(payload)
if err != nil {
@@ -117,3 +116,84 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
}
}
func TestLoadUsesMillisecondsForIntervals(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"heartbeat_interval": 30000,
"sync_interval": 45000,
"request_timeout": 1500,
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.HeartbeatInterval.Duration() != 30*time.Second {
t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval)
}
if cfg.SyncInterval.Duration() != 45*time.Second {
t.Fatalf("unexpected sync interval: %s", cfg.SyncInterval)
}
if cfg.RequestTimeout.Duration() != 1500*time.Millisecond {
t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout)
}
}
func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","agent_token":"token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
cfg.NginxVersion = "1.25.5"
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
cfg.SyncInterval = MillisecondDuration(6 * time.Second)
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
if err = cfg.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read saved config: %v", err)
}
var decoded map[string]any
if err = json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to decode saved config: %v", err)
}
if _, ok := decoded["agent_version"]; ok {
t.Fatal("agent_version should not be persisted")
}
if _, ok := decoded["nginx_version"]; ok {
t.Fatal("nginx_version should not be persisted")
}
if decoded["heartbeat_interval"] != float64(5000) {
t.Fatalf("unexpected heartbeat interval: %#v", decoded["heartbeat_interval"])
}
if decoded["sync_interval"] != float64(6000) {
t.Fatalf("unexpected sync interval: %#v", decoded["sync_interval"])
}
if decoded["request_timeout"] != float64(7000) {
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
}
}
+54
View File
@@ -0,0 +1,54 @@
package config
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type MillisecondDuration time.Duration
func (d MillisecondDuration) Duration() time.Duration {
return time.Duration(d)
}
func (d MillisecondDuration) String() string {
return time.Duration(d).String()
}
func (d *MillisecondDuration) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*d = 0
return nil
}
if strings.HasPrefix(raw, "\"") {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
text = strings.TrimSpace(text)
if text == "" {
*d = 0
return nil
}
parsed, err := time.ParseDuration(text)
if err != nil {
return fmt.Errorf("invalid duration string %q: %w", text, err)
}
*d = MillisecondDuration(parsed)
return nil
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid duration milliseconds %q: %w", raw, err)
}
*d = MillisecondDuration(time.Duration(ms) * time.Millisecond)
return nil
}
func (d MillisecondDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).Milliseconds())
}
+3
View File
@@ -0,0 +1,3 @@
package config
const AgentVersion = "0.1.0"
+50
View File
@@ -263,6 +263,56 @@ func NewExecutor(options ExecutorOptions) Executor {
}
}
func DetectVersion(ctx context.Context, options ExecutorOptions) string {
version, err := detectVersion(ctx, options, &OSCommandRunner{})
if err != nil {
log.Printf("detect nginx version failed: %v", err)
return ""
}
log.Printf("detected nginx version: %s", version)
return version
}
func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandRunner) (string, error) {
if runner == nil {
runner = &OSCommandRunner{}
}
if options.NginxPath != "" {
output, err := runner.Run(ctx, options.NginxPath, "-v")
if err != nil {
return "", fmt.Errorf("run nginx -v failed: %w: %s", err, string(output))
}
version := parseNginxVersion(string(output))
if version == "" {
return "", errors.New("cannot parse nginx version from binary output")
}
return version, nil
}
output, err := runner.Run(ctx, options.DockerBinary, "run", "--rm", options.Image, "nginx", "-v")
if err != nil {
return "", fmt.Errorf("run docker nginx -v failed: %w: %s", err, string(output))
}
version := parseNginxVersion(string(output))
if version == "" {
return "", errors.New("cannot parse nginx version from docker output")
}
return version, nil
}
func parseNginxVersion(output string) string {
start := strings.Index(output, "nginx/")
if start < 0 {
return ""
}
version := output[start+len("nginx/"):]
for i, r := range version {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return version[:i]
}
}
return version
}
type backupState struct {
RouteExisted bool
RouteData []byte
+41
View File
@@ -203,6 +203,47 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
}
}
func TestDetectVersionFromBinary(t *testing.T) {
version, err := detectVersion(context.Background(), ExecutorOptions{
NginxPath: "/opt/nginx/sbin/nginx",
}, &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
return []byte("nginx version: nginx/1.25.5\n"), nil
},
})
if err != nil {
t.Fatalf("detectVersion failed: %v", err)
}
if version != "1.25.5" {
t.Fatalf("unexpected version: %s", version)
}
}
func TestDetectVersionFromDockerImage(t *testing.T) {
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
return []byte("nginx version: nginx/1.27.4\n"), nil
},
}
version, err := detectVersion(context.Background(), ExecutorOptions{
DockerBinary: "docker",
Image: "nginx:stable-alpine",
}, runner)
if err != nil {
t.Fatalf("detectVersion failed: %v", err)
}
if version != "1.27.4" {
t.Fatalf("unexpected version: %s", version)
}
if len(runner.calls) != 1 {
t.Fatalf("expected one command call, got %d", len(runner.calls))
}
expectedArgs := []string{"run", "--rm", "nginx:stable-alpine", "nginx", "-v"}
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
t.Fatalf("unexpected docker args: %#v", runner.calls[0].args)
}
}
func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
tempDir := t.TempDir()
manager := &Manager{
+1 -1
View File
@@ -9,7 +9,7 @@ import (
var StartTime = time.Now().Unix() // unit: second
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
var SystemName = "项目模板"
var SystemName = "ATSFlare"
var ServerAddress = "http://localhost:3000"
var Footer = ""
var HomePageLink = ""
+1 -1
View File
@@ -9,7 +9,7 @@
name="description"
content="Web site created using create-react-app"
/>
<title>项目模板</title>
<title>ATSFlare</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
-6
View File
@@ -56,12 +56,6 @@ const headerButtons = [
icon: 'globe',
admin: true,
},
{
name: '文件',
to: '/file',
icon: 'file',
admin: true,
},
{
name: '用户',
to: '/user',
+1 -1
View File
@@ -17,7 +17,7 @@ export function isRoot() {
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return '项目模板';
if (!system_name) return 'ATSFlare';
return system_name;
}
+39 -42
View File
@@ -1,4 +1,4 @@
# ATSFlare 部署与联调说明(当前基线)
# ATSFlare 部署说明
本文档仅保留当前可用基线的最小部署方式,用于第三版开发前后的本地部署、联调与回归验证。
@@ -63,45 +63,43 @@ go run .
### 3.1 节点专属 `agent_token`
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"agent_version": "0.1.0",
"nginx_version": "1.25.5",
"data_dir": "./data",
"nginx_container_name": "atsflare-nginx",
"nginx_docker_image": "nginx:stable-alpine",
"heartbeat_interval": 30000000000,
"sync_interval": 30000000000,
"request_timeout": 10000000000
}
```
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"nginx_container_name": "atsflare-nginx",
"nginx_docker_image": "nginx:stable-alpine",
"heartbeat_interval": 30000,
"sync_interval": 30000,
"request_timeout": 10000
}
```
### 3.2 全局 `discovery_token`
```json
{
"server_url": "http://127.0.0.1:3000",
"discovery_token": "replace-with-global-discovery-token",
"agent_version": "0.1.0",
"nginx_version": "1.25.5",
"data_dir": "./data",
"nginx_container_name": "atsflare-nginx",
"nginx_docker_image": "nginx:stable-alpine",
"heartbeat_interval": 30000000000,
"sync_interval": 30000000000,
"request_timeout": 10000000000
}
```
说明:
* 时间字段当前仍使用纳秒整数
* `agent_token` 与 `discovery_token` 至少填写一个
* 若 `agent_token` 为空且 `discovery_token` 存在,Agent 会自动注册并写回新的专属 `agent_token`
* `node_name` 与 `node_ip` 可省略,未填写时自动探测
* 未配置 `nginx_path` 时,默认使用 Docker Nginx 容器
```json
{
"server_url": "http://127.0.0.1:3000",
"discovery_token": "replace-with-global-discovery-token",
"data_dir": "./data",
"nginx_container_name": "atsflare-nginx",
"nginx_docker_image": "nginx:stable-alpine",
"heartbeat_interval": 30000,
"sync_interval": 30000,
"request_timeout": 10000
}
```
说明:
* `agent_version` 由 Agent 代码内常量提供,升级时同步修改代码
* `nginx_version` 由 Agent 启动时执行命令自动探测
* 时间字段使用毫秒整数
* `agent_token` 与 `discovery_token` 至少填写一个
* 若 `agent_token` 为空且 `discovery_token` 存在,Agent 会自动注册并写回新的专属 `agent_token`
* `node_name` 与 `node_ip` 可省略,未填写时自动探测
* 未配置 `nginx_path` 时,默认使用 Docker Nginx 容器
---
@@ -197,11 +195,10 @@ npm run build
## 7. 当前已知限制
* 时间字段仍使用纳秒整数,不够友好
* 暂未内置 systemd unit 文件
* 暂未提供一键部署脚本
* Docker 模式仍是 MVP 级封装
* 联调以手工步骤为主
* 暂未内置 systemd unit 文件
* 暂未提供一键部署脚本
* Docker 模式仍是 MVP 级封装
* 联调以手工步骤为主
---