mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-03 15:31:22 +08:00
Compare commits
2 Commits
feat/compl
...
v2.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a239d3d5 | ||
|
|
91d26bb92a |
@@ -26,7 +26,7 @@ type Config struct {
|
|||||||
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
||||||
// PollInterval 命令轮询间隔,默认 5s
|
// PollInterval 命令轮询间隔,默认 5s
|
||||||
PollInterval string `yaml:"pollInterval"`
|
PollInterval string `yaml:"pollInterval"`
|
||||||
// TempDir 备份临时目录,默认 /tmp/backupx-agent
|
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
|
||||||
TempDir string `yaml:"tempDir"`
|
TempDir string `yaml:"tempDir"`
|
||||||
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
||||||
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
||||||
@@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
|
|||||||
cfg.PollInterval = "5s"
|
cfg.PollInterval = "5s"
|
||||||
}
|
}
|
||||||
if cfg.TempDir == "" {
|
if cfg.TempDir == "" {
|
||||||
cfg.TempDir = "/tmp/backupx-agent"
|
cfg.TempDir = "/var/lib/backupx-agent/tmp"
|
||||||
}
|
}
|
||||||
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
|
|||||||
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
|
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
|
||||||
t.Errorf("default intervals not applied: %+v", cfg)
|
t.Errorf("default intervals not applied: %+v", cfg)
|
||||||
}
|
}
|
||||||
if cfg.TempDir != "/tmp/backupx-agent" {
|
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
|
||||||
t.Errorf("default tempdir: %q", cfg.TempDir)
|
t.Errorf("default tempdir: %q", cfg.TempDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,10 +20,10 @@ import (
|
|||||||
|
|
||||||
// Executor 负责在 Agent 本地执行命令。
|
// Executor 负责在 Agent 本地执行命令。
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
client *MasterClient
|
client *MasterClient
|
||||||
tempDir string
|
tempDir string
|
||||||
backupRegistry *backup.Registry
|
backupRegistry *backup.Registry
|
||||||
storageRegistry *storage.Registry
|
storageRegistry *storage.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
|
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
|
||||||
@@ -59,6 +60,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
|
|||||||
// 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。
|
// 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。
|
||||||
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
|
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
|
||||||
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
|
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
|
||||||
|
if err := e.ensureTempDir(); err != nil {
|
||||||
|
e.reportRecordFailure(ctx, recordID, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 1) 拉取任务规格
|
// 1) 拉取任务规格
|
||||||
spec, err := e.client.GetTaskSpec(ctx, taskID)
|
spec, err := e.client.GetTaskSpec(ctx, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,10 +80,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
|
|||||||
|
|
||||||
// 2) 构造 backup.TaskSpec 并找对应 runner
|
// 2) 构造 backup.TaskSpec 并找对应 runner
|
||||||
startedAt := time.Now().UTC()
|
startedAt := time.Now().UTC()
|
||||||
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
|
||||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("创建临时目录失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
|
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
|
||||||
runner, err := e.backupRegistry.Runner(backupSpec.Type)
|
runner, err := e.backupRegistry.Runner(backupSpec.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,22 +186,8 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
|
|||||||
|
|
||||||
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
|
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
|
||||||
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
||||||
var sourcePaths []string
|
sourcePaths := parseStringListField(spec.SourcePaths)
|
||||||
if strings.TrimSpace(spec.SourcePaths) != "" {
|
excludes := parseStringListField(spec.ExcludePatterns)
|
||||||
for _, p := range strings.Split(spec.SourcePaths, "\n") {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
sourcePaths = append(sourcePaths, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var excludes []string
|
|
||||||
if strings.TrimSpace(spec.ExcludePatterns) != "" {
|
|
||||||
for _, p := range strings.Split(spec.ExcludePatterns, "\n") {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
excludes = append(excludes, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return backup.TaskSpec{
|
return backup.TaskSpec{
|
||||||
ID: spec.TaskID,
|
ID: spec.TaskID,
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
@@ -222,6 +210,37 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Executor) ensureTempDir() error {
|
||||||
|
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create agent temp dir: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringListField(value string) []string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || trimmed == "[]" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var jsonItems []string
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
|
||||||
|
return compactStringList(jsonItems)
|
||||||
|
}
|
||||||
|
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
|
||||||
|
return r == '\n' || r == '\r'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactStringList(items []string) []string {
|
||||||
|
result := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// recordLogger 把 runner 日志回传到 Master 记录。
|
// recordLogger 把 runner 日志回传到 Master 记录。
|
||||||
// 实现 backup.LogWriter,每条日志追加到 record.log_content。
|
// 实现 backup.LogWriter,每条日志追加到 record.log_content。
|
||||||
type recordLogger struct {
|
type recordLogger struct {
|
||||||
@@ -240,8 +259,8 @@ func (l *recordLogger) WriteLine(message string) {
|
|||||||
|
|
||||||
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
|
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
|
||||||
type restoreLogger struct {
|
type restoreLogger struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
client *MasterClient
|
client *MasterClient
|
||||||
restoreID uint
|
restoreID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +289,11 @@ func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, t
|
|||||||
// - 执行:backup.Registry.Runner(spec.Type).Restore
|
// - 执行:backup.Registry.Runner(spec.Type).Restore
|
||||||
// - 上报:通过 UpdateRestore(status/logAppend)
|
// - 上报:通过 UpdateRestore(status/logAppend)
|
||||||
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
|
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
|
||||||
|
if err := e.ensureTempDir(); err != nil {
|
||||||
|
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
|
spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err))
|
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err))
|
||||||
@@ -282,10 +306,6 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
|
|||||||
}
|
}
|
||||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
|
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
|
||||||
|
|
||||||
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
|
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
|
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
|
||||||
|
|||||||
34
server/internal/agent/executor_test.go
Normal file
34
server/internal/agent/executor_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
|
||||||
|
spec := &TaskSpec{
|
||||||
|
TaskID: 7,
|
||||||
|
Name: "root-files",
|
||||||
|
Type: "file",
|
||||||
|
SourcePaths: `["/root","/etc"]`,
|
||||||
|
ExcludePatterns: `["*.log","tmp"]`,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildBackupTaskSpec(spec, time.Unix(0, 0), "/var/lib/backupx-agent/tmp")
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got.SourcePaths, []string{"/root", "/etc"}) {
|
||||||
|
t.Fatalf("source paths = %#v", got.SourcePaths)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.ExcludePatterns, []string{"*.log", "tmp"}) {
|
||||||
|
t.Fatalf("exclude patterns = %#v", got.ExcludePatterns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) {
|
||||||
|
got := parseStringListField("/root\n /etc \n")
|
||||||
|
want := []string{"/root", "/etc"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DirEntry Agent 返回给 Master 的目录项。
|
// DirEntry Agent 返回给 Master 的目录项。
|
||||||
@@ -17,8 +18,8 @@ type DirEntry struct {
|
|||||||
|
|
||||||
// listLocalDir 列出 Agent 所在机器的指定路径。
|
// listLocalDir 列出 Agent 所在机器的指定路径。
|
||||||
func listLocalDir(path string) ([]DirEntry, error) {
|
func listLocalDir(path string) ([]DirEntry, error) {
|
||||||
cleaned := filepath.Clean(path)
|
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||||
if cleaned == "" {
|
if strings.TrimSpace(path) == "" || cleaned == "." {
|
||||||
cleaned = "/"
|
cleaned = "/"
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(cleaned)
|
entries, err := os.ReadDir(cleaned)
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ func TestListLocalDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListLocalDirEmptyPathUsesRoot(t *testing.T) {
|
||||||
|
entries, err := listLocalDir("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list root: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatalf("expected root entries")
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !filepath.IsAbs(entry.Path) {
|
||||||
|
t.Fatalf("entry path should be absolute: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSplitCommaOrNewline(t *testing.T) {
|
func TestSplitCommaOrNewline(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
|
|||||||
@@ -37,19 +37,22 @@ func TestRenderScriptBashBootstrap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) {
|
func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
|
||||||
got, err := RenderScript(testCtx)
|
got, err := RenderScript(testCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render err: %v", err)
|
t.Fatalf("render err: %v", err)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"getent group backupx",
|
"/var/lib/backupx-agent/tmp",
|
||||||
"groupadd --system backupx",
|
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
|
||||||
"useradd --system --gid backupx",
|
|
||||||
"Group=backupx",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("script missing %q:\n%s", want, got)
|
t.Errorf("script missing %q:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, forbidden := range []string{"User=backupx", "Group=backupx", "NoNewPrivileges=true"} {
|
||||||
|
if strings.Contains(got, forbidden) {
|
||||||
|
t.Errorf("script should not contain %q for bare-metal backups:\n%s", forbidden, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ func TestRenderScriptSystemd(t *testing.T) {
|
|||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
||||||
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
||||||
|
"/var/lib/backupx-agent/tmp",
|
||||||
"systemctl daemon-reload",
|
"systemctl daemon-reload",
|
||||||
"systemctl enable --now backupx-agent",
|
"systemctl enable --now backupx-agent",
|
||||||
|
"systemctl status backupx-agent",
|
||||||
"X-Agent-Token: ${AGENT_TOKEN}",
|
"X-Agent-Token: ${AGENT_TOKEN}",
|
||||||
"MASTER_URL=\"https://master.example.com\"",
|
"MASTER_URL=\"https://master.example.com\"",
|
||||||
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
|
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
|
||||||
@@ -56,6 +58,9 @@ func TestRenderScriptForeground(t *testing.T) {
|
|||||||
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
|
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
|
||||||
t.Errorf("foreground script missing exec line:\n%s", got)
|
t.Errorf("foreground script missing exec line:\n%s", got)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "/var/lib/backupx-agent/tmp") {
|
||||||
|
t.Errorf("foreground script missing dedicated temp dir:\n%s", got)
|
||||||
|
}
|
||||||
if strings.Contains(got, "systemctl daemon-reload") {
|
if strings.Contains(got, "systemctl daemon-reload") {
|
||||||
t.Errorf("foreground script should not reference systemctl:\n%s", got)
|
t.Errorf("foreground script should not reference systemctl:\n%s", got)
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,9 @@ func TestRenderScriptDocker(t *testing.T) {
|
|||||||
if !strings.Contains(got, "docker run") {
|
if !strings.Contains(got, "docker run") {
|
||||||
t.Errorf("docker script missing `docker run`:\n%s", got)
|
t.Errorf("docker script missing `docker run`:\n%s", got)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
||||||
|
t.Errorf("docker script missing agent data volume:\n%s", got)
|
||||||
|
}
|
||||||
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
|
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
|
||||||
t.Errorf("docker script missing image tag reference:\n%s", got)
|
t.Errorf("docker script missing image tag reference:\n%s", got)
|
||||||
}
|
}
|
||||||
@@ -95,14 +103,17 @@ func TestRenderComposeYaml(t *testing.T) {
|
|||||||
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
|
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
|
||||||
t.Errorf("compose missing token env:\n%s", got)
|
t.Errorf("compose missing token env:\n%s", got)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
||||||
|
t.Errorf("compose missing agent data volume:\n%s", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
||||||
bad := []string{
|
bad := []string{
|
||||||
"https://example.com\" other: inject", // 含引号和空格
|
"https://example.com\" other: inject", // 含引号和空格
|
||||||
"javascript:alert(1)", // scheme 非法
|
"javascript:alert(1)", // scheme 非法
|
||||||
"https://example.com\n- privileged", // 含换行,YAML 注入经典 payload
|
"https://example.com\n- privileged", // 含换行,YAML 注入经典 payload
|
||||||
"", // 空
|
"", // 空
|
||||||
}
|
}
|
||||||
for _, u := range bad {
|
for _, u := range bad {
|
||||||
ctx := testCtx
|
ctx := testCtx
|
||||||
@@ -161,8 +172,8 @@ func TestDownloadBaseMapping(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderScriptDefaultsApplied(t *testing.T) {
|
func TestRenderScriptDefaultsApplied(t *testing.T) {
|
||||||
ctx := testCtx
|
ctx := testCtx
|
||||||
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
|
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
|
||||||
ctx.DownloadBase = "" // 应被默认为 github
|
ctx.DownloadBase = "" // 应被默认为 github
|
||||||
got, err := RenderScript(ctx)
|
got, err := RenderScript(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render err: %v", err)
|
t.Fatalf("render err: %v", err)
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ services:
|
|||||||
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
||||||
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/backupx-agent:/tmp/backupx-agent
|
- /var/lib/backupx-agent:/var/lib/backupx-agent
|
||||||
|
|||||||
@@ -47,30 +47,10 @@ else
|
|||||||
fi
|
fi
|
||||||
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
||||||
|
|
||||||
# 4. 安装二进制 + 用户
|
# 4. 安装二进制 + 数据目录
|
||||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||||
if ! getent group backupx >/dev/null 2>&1; then
|
install -d -m 0755 "$INSTALL_PREFIX"
|
||||||
if command -v groupadd >/dev/null 2>&1; then
|
install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
|
||||||
groupadd --system backupx
|
|
||||||
elif command -v addgroup >/dev/null 2>&1; then
|
|
||||||
addgroup --system backupx
|
|
||||||
else
|
|
||||||
echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if ! id backupx >/dev/null 2>&1; then
|
|
||||||
if command -v useradd >/dev/null 2>&1; then
|
|
||||||
useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
|
||||||
elif command -v adduser >/dev/null 2>&1; then
|
|
||||||
adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
|
||||||
else
|
|
||||||
echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; }
|
|
||||||
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
|
|
||||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -85,14 +65,11 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=backupx
|
|
||||||
Group=backupx
|
|
||||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
||||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
|
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -111,6 +88,7 @@ for i in $(seq 1 15); do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
||||||
|
echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。"
|
||||||
exit 2
|
exit 2
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -119,7 +97,7 @@ exit 2
|
|||||||
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
||||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
||||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
||||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
|
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .Mode "docker"}}
|
{{if eq .Mode "docker"}}
|
||||||
@@ -131,7 +109,7 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
|
|||||||
docker run -d --name backupx-agent --restart=unless-stopped \
|
docker run -d --name backupx-agent --restart=unless-stopped \
|
||||||
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
||||||
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
||||||
-v /var/lib/backupx-agent:/tmp/backupx-agent \
|
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
|
||||||
"awuqing/backupx:${AGENT_VERSION}" agent
|
"awuqing/backupx:${AGENT_VERSION}" agent
|
||||||
echo "✓ 容器已启动"
|
echo "✓ 容器已启动"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type Service struct {
|
|||||||
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
|
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
|
||||||
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
return &Service{
|
return &Service{
|
||||||
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)),
|
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.Local)),
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
runner: runner,
|
runner: runner,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
|||||||
@@ -68,3 +68,37 @@ func TestServiceSyncTaskAndTrigger(t *testing.T) {
|
|||||||
t.Fatalf("expected scheduled runner to be triggered")
|
t.Fatalf("expected scheduled runner to be triggered")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceSchedulesTasksInLocalTimezone(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation returned error: %v", err)
|
||||||
|
}
|
||||||
|
originalLocal := time.Local
|
||||||
|
time.Local = location
|
||||||
|
t.Cleanup(func() {
|
||||||
|
time.Local = originalLocal
|
||||||
|
})
|
||||||
|
|
||||||
|
service := NewService(&fakeTaskRepository{}, &fakeRunner{}, nil)
|
||||||
|
if got := service.cron.Location(); got != location {
|
||||||
|
t.Fatalf("cron location = %v, want %v", got, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := &model.BackupTask{ID: 1, Enabled: true, CronExpr: "0 5 * * *"}
|
||||||
|
if err := service.SyncTask(context.Background(), task); err != nil {
|
||||||
|
t.Fatalf("SyncTask returned error: %v", err)
|
||||||
|
}
|
||||||
|
entryID, ok := service.entries[task.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cron entry for task %d", task.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := service.cron.Entry(entryID)
|
||||||
|
now := time.Date(2026, 4, 30, 4, 0, 0, 0, location)
|
||||||
|
got := entry.Schedule.Next(now)
|
||||||
|
want := time.Date(2026, 4, 30, 5, 0, 0, 0, location)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("next run = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user