diff --git a/README.md b/README.md index c4a4b92..56253cf 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ sudo mkdir -p /opt/docker-compose-backup sudo cp docker-compose-backup-linux-arm64 /opt/docker-compose-backup/docker-compose-backup sudo chmod +x /opt/docker-compose-backup/docker-compose-backup sudo cp config.yaml /opt/docker-compose-backup/config.yaml -sudo mkdir -p /opt/docker-compose-backup/backups +sudo mkdir -p /opt/docker-compose-backup/backups /opt/docker-compose-backup/tmp sudo cp docker-compose-backup.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now docker-compose-backup ``` -The example config uses `/opt/docker-compose-backup/backups`, which is allowed by the service sandbox. +The example config uses `/opt/docker-compose-backup/backups`, which is allowed by the service sandbox. The unit's `ReadWritePaths` covers the whole install dir, so set `global.temp_dir` to a subdirectory there (e.g. `/opt/docker-compose-backup/tmp`). The unit intentionally does **not** enable `PrivateTmp`, since a private `/tmp` is often small and would be exhausted when copying large project directories. ## Configuration @@ -69,7 +69,7 @@ See [config.example.yaml](config.example.yaml) for a full annotated example. ```yaml global: backup_dir: /opt/docker-compose-backup/backups - temp_dir: /tmp/docker-backup # optional, defaults to OS temp + temp_dir: /opt/docker-compose-backup/tmp # optional, defaults to OS temp projects: - name: myapp diff --git a/cmd/backup/list.go b/cmd/backup/list.go index 7fdf86e..6120391 100644 --- a/cmd/backup/list.go +++ b/cmd/backup/list.go @@ -56,7 +56,7 @@ func runList(cmd *cobra.Command, args []string) error { var backups []string for _, e := range entries { - if !e.IsDir() { + if !e.IsDir() && backup.IsBackupArchive(proj.Name, e.Name()) { backups = append(backups, e.Name()) } } diff --git a/config.example.yaml b/config.example.yaml index e46bd12..3bf1526 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,7 +1,10 @@ # docker-compose-backup example configuration global: backup_dir: /opt/docker-compose-backup/backups - # temp_dir: /tmp/docker-compose-backup # defaults to OS temp dir + # Staging area for the pre-compression copy. Defaults to the OS temp dir. + # Under the bundled systemd unit, set this inside the install dir so it stays + # within the sandbox's writable path (the unit no longer uses PrivateTmp). + temp_dir: /opt/docker-compose-backup/tmp projects: - name: myapp diff --git a/docker-compose-backup.service b/docker-compose-backup.service index 9bf486f..520392d 100644 --- a/docker-compose-backup.service +++ b/docker-compose-backup.service @@ -19,8 +19,11 @@ User=root NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes -ReadWritePaths=/opt/docker-compose-backup /tmp -PrivateTmp=yes +# The backup copies whole project directories into a staging area before +# compressing. Keep staging inside the writable install dir (see temp_dir in +# config.yaml) rather than /tmp, and do NOT use PrivateTmp — a private /tmp is +# often small/tmpfs-backed and can be exhausted by large project copies. +ReadWritePaths=/opt/docker-compose-backup ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes diff --git a/go.mod b/go.mod index a804734..93a0fad 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.misaka.ren/M1saka/docker_backup -go 1.26.4 +go 1.26 require ( github.com/aws/aws-sdk-go-v2 v1.42.1 diff --git a/internal/backup/engine.go b/internal/backup/engine.go index e50e6bb..0a8969f 100644 --- a/internal/backup/engine.go +++ b/internal/backup/engine.go @@ -83,9 +83,12 @@ func (e *Engine) backupOne(ctx context.Context, proj config.ProjectConfig) Resul executor := NewComposeExecutor(proj.Path, proj.ComposeFile) - // 1. Stop the compose project. + // 1. Stop the compose project. If the stop is interrupted midway + // (e.g. the context is cancelled), containers may be left stopped, so + // always attempt a restart before returning. stopStart := time.Now() if err := executor.Stop(ctx); err != nil { + _ = restartCompose(executor) result.Error = fmt.Errorf("stop: %w", err) result.Duration = time.Since(start) return result @@ -226,9 +229,14 @@ func createTarGz(ctx context.Context, srcDir, dstPath string) error { closeErr := closeTarGz(tw, gw, f) if walkErr != nil { + _ = os.Remove(dstPath) return walkErr } - return closeErr + if closeErr != nil { + _ = os.Remove(dstPath) + return closeErr + } + return nil } func closeTarGz(tw *tar.Writer, gw *gzip.Writer, f *os.File) error { diff --git a/internal/backup/retention.go b/internal/backup/retention.go index 42083f2..433bdd3 100644 --- a/internal/backup/retention.go +++ b/internal/backup/retention.go @@ -87,5 +87,10 @@ func ApplyRetention(backupDir, projectName string, policy RetentionPolicy) (int, } func isBackupArchive(projectName, name string) bool { + return IsBackupArchive(projectName, name) +} + +// IsBackupArchive reports whether name is a backup archive for the project. +func IsBackupArchive(projectName, name string) bool { return strings.HasPrefix(name, projectName+"-") && strings.HasSuffix(name, ".tar.gz") } \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 0fefccc..15afc63 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -85,7 +85,9 @@ func (c *Config) Validate() error { if p.ComposeFile == "" { p.ComposeFile = "docker-compose.yml" } - if p.Retention.Count <= 0 { + // Apply the default count only when no retention policy is set at all. + // This lets a user configure days-only retention (count omitted). + if p.Retention.Count <= 0 && p.Retention.Days <= 0 { p.Retention.Count = DefaultRetentionCount } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 45fddd8..ceab7aa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -45,6 +45,34 @@ projects: } } +func TestLoadDaysOnlyRetention(t *testing.T) { + yaml := ` +global: + backup_dir: /tmp/backups +projects: + - name: testapp + path: /opt/testapp + retention: + days: 30 +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + p := cfg.Projects[0] + if p.Retention.Count != 0 { + t.Errorf("count should stay 0 for days-only retention, got %d", p.Retention.Count) + } + if p.Retention.Days != 30 { + t.Errorf("days = %d", p.Retention.Days) + } +} + func TestLoadMissingBackupDir(t *testing.T) { yaml := ` projects: