Compare commits

...

2 Commits

Author SHA1 Message Date
Awuqing
81a239d3d5 feat(Agent): 修复裸机安装配置 2026-05-01 16:41:10 +08:00
Awuqing
91d26bb92a fix: respect local timezone for scheduler 2026-05-01 15:27:59 +08:00
12 changed files with 172 additions and 76 deletions

View File

@@ -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

View File

@@ -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)
} }
} }

View File

@@ -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
// - 上报:通过 UpdateRestorestatus/logAppend // - 上报:通过 UpdateRestorestatus/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))

View 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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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] 前台启动 agentCtrl+C 退出)" echo "[3/3] 前台启动 agentCtrl+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}}

View File

@@ -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,

View File

@@ -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)
}
}