feat: Add backup lock
This commit is contained in:
9 files changed
+329
-9
No files matched your search
@@ -8,6 +8,7 @@ A lightweight wrapper around [Restic](https://restic.net/) that adds automatic d
|
||||
- **Docker Auto-Discovery:** When running `backup`, `resticontainer` queries the Docker daemon for active containers with specific labels.
|
||||
- **Dynamic Path Resolution:** Translates container volume mounts back to the host filesystem.
|
||||
- **Pre & Post Hooks:** Execute shell scripts inside the container immediately before and after the backup runs (e.g. `pg_dump`, `FLUSH TABLES`).
|
||||
- **Down-Container Resilience:** Remembers the paths it has backed up in a lock file, so they keep being backed up even when a container is later stopped or removed.
|
||||
|
||||
## Docker Labels
|
||||
|
||||
@@ -18,6 +19,7 @@ To enable a container to be backed up by `resticontainer`, add the following lab
|
||||
- `restic.hooks.pre-backup`: *(Optional)* A command to run inside the container before the backup starts.
|
||||
- `restic.hooks.post-backup`: *(Optional)* A command to run inside the container after the backup finishes (runs even if the backup fails).
|
||||
- `restic.backup.stop=true`: *(Optional)* Automatically stops the container *after* running pre-hooks, performs the backup, and starts it again *before* running post-hooks. Great for ensuring database files (like SQLite) are cleanly flushed to disk. (You can also use `restic.backup.down=true`).
|
||||
- `restic.backup.lock=false`: *(Optional, default `true`)* When enabled (the default), this container's resolved paths are recorded in the lock file so they keep being backed up even while the container is stopped or removed. Set it to `false` to opt out — the container is then backed up **only while running**, and any existing lock entry for it is dropped.
|
||||
|
||||
## Example: PostgreSQL Backup
|
||||
|
||||
@@ -46,6 +48,7 @@ Run `resticontainer backup` just as you would run `restic backup`, providing you
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /:/hostfs:ro \
|
||||
-v resticontainer_state:/var/lib/resticontainer \
|
||||
-e RESTIC_HOSTFS=/hostfs \
|
||||
-e RESTIC_PASSWORD="your-password" \
|
||||
ghcr.io/deytenit/resticontainer:latest \
|
||||
@@ -54,6 +57,15 @@ docker run --rm \
|
||||
|
||||
> **Note**: You must mount the Docker socket so `resticontainer` can query the API and execute hooks, and you must mount the host root (`/`) to `/hostfs:ro` so `resticontainer` can read the data from the host filesystem.
|
||||
|
||||
## The Backup Lock File
|
||||
|
||||
Docker auto-discovery only sees *running* containers, so a container that is stopped or removed at backup time would otherwise be silently skipped. To prevent gaps, `resticontainer` records each backed-up container's resolved host paths in a lock file and, on every run, also backs up any remembered paths whose owning container is no longer running.
|
||||
|
||||
- **Location:** defaults to `/var/lib/resticontainer/restic-backup-lock.json`; override with the `RESTICONTAINER_LOCK` environment variable.
|
||||
- **Persistence:** mount a writable volume at that directory (as in the example above) so the lock file survives container restarts.
|
||||
- **Skip-missing:** remembered paths that no longer exist on disk are skipped, so removing a volume cleanly drops it from future backups.
|
||||
- **Opt out:** set `restic.backup.lock=false` on a container to keep it out of the lock file.
|
||||
|
||||
## Versioning
|
||||
|
||||
The `resticontainer` Docker image tags follow the upstream `restic` versions. For example, `ghcr.io/deytenit/resticontainer:0.16.4` contains `resticontainer` bundled with `restic v0.16.4`.
|
||||
@@ -65,3 +77,7 @@ This project is a thin orchestration layer standing on the shoulders of giants.
|
||||
## License
|
||||
|
||||
This project is licensed under the BSD 2-Clause License, adhering to the same licensing terms as Restic itself. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
*Portions of this project's source code may have been developed with the assistance of AI code-generation tools. Contributions made with the help of such tools are welcome, provided the code quality stays within an acceptable range and the contributor fully understands the submission they are making.*
|
||||
@@ -5,12 +5,18 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"resticontainer/pkg/apptypes"
|
||||
"resticontainer/pkg/discovery"
|
||||
"resticontainer/pkg/dockerclient"
|
||||
"resticontainer/pkg/lockfile"
|
||||
"resticontainer/pkg/runner"
|
||||
)
|
||||
|
||||
const defaultLockPath = "/var/lib/resticontainer/restic-backup-lock.json"
|
||||
|
||||
func runResticNative(ctx context.Context, args []string) error {
|
||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -47,6 +53,16 @@ func main() {
|
||||
hostfs = "/hostfs"
|
||||
}
|
||||
|
||||
lockPath := os.Getenv("RESTICONTAINER_LOCK")
|
||||
if lockPath == "" {
|
||||
lockPath = defaultLockPath
|
||||
}
|
||||
|
||||
lock, err := lockfile.Load(lockPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not read lock file %s, continuing without it: %v\n", lockPath, err)
|
||||
}
|
||||
|
||||
containers, err := client.ListContainers(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to list containers: %v\n", err)
|
||||
@@ -65,10 +81,54 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the lock file from the containers discovered this run: remember paths for
|
||||
// containers that opt in, forget those that opt out. Persist before the backup so
|
||||
// the state survives even if the backup itself fails.
|
||||
now := time.Now().UTC()
|
||||
for _, t := range targets {
|
||||
if t.Lock {
|
||||
lock.Upsert(t.ContainerID, t.Name, t.Paths, now)
|
||||
} else {
|
||||
lock.Delete(t.ContainerID)
|
||||
}
|
||||
}
|
||||
if err := lockfile.Save(lockPath, lock); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not write lock file %s: %v\n", lockPath, err)
|
||||
}
|
||||
|
||||
backupPaths := mergeBackupPaths(targets, lock)
|
||||
|
||||
resticArgs := os.Args[2:]
|
||||
err = runner.RunHooksAndBackup(ctx, client, targets, resticArgs, runResticNative)
|
||||
err = runner.RunHooksAndBackup(ctx, client, targets, backupPaths, resticArgs, runResticNative)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Backup failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeBackupPaths unions the freshly resolved paths of every running target (so a
|
||||
// container with restic.backup.lock=false is still backed up while running) with the
|
||||
// existence-filtered paths remembered in the lock file (covering containers that are
|
||||
// down or removed), deduped and stably ordered.
|
||||
func mergeBackupPaths(targets []*apptypes.Target, lock *lockfile.LockFile) []string {
|
||||
set := map[string]struct{}{}
|
||||
for _, t := range targets {
|
||||
for _, p := range t.Paths {
|
||||
set[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, p := range lock.BackupPaths(pathExists) {
|
||||
set[p] = struct{}{}
|
||||
}
|
||||
paths := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func pathExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package apptypes
|
||||
|
||||
type Target struct {
|
||||
ContainerID string
|
||||
Name string
|
||||
PreHook string
|
||||
PostHook string
|
||||
Paths []string
|
||||
Stop bool
|
||||
Lock bool
|
||||
}
|
||||
@@ -16,9 +16,11 @@ func ParseContainer(container types.ContainerJSON, hostMountPrefix string) (*app
|
||||
|
||||
target := &apptypes.Target{
|
||||
ContainerID: container.ID,
|
||||
Name: strings.TrimPrefix(container.Name, "/"),
|
||||
PreHook: labels["restic.hooks.pre-backup"],
|
||||
PostHook: labels["restic.hooks.post-backup"],
|
||||
Stop: labels["restic.backup.stop"] == "true" || labels["restic.backup.down"] == "true",
|
||||
Lock: labels["restic.backup.lock"] != "false",
|
||||
}
|
||||
|
||||
pathsStr := labels["restic.backup.paths"]
|
||||
|
||||
@@ -85,3 +85,28 @@ func TestParseContainer_NotEnabled(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, target)
|
||||
}
|
||||
|
||||
func TestParseContainer_LockLabelAndName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"absent defaults to true", map[string]string{"restic.enable": "true"}, true},
|
||||
{"explicit true", map[string]string{"restic.enable": "true", "restic.backup.lock": "true"}, true},
|
||||
{"explicit false disables", map[string]string{"restic.enable": "true", "restic.backup.lock": "false"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cJSON := types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{ID: "id", Name: "/myctr"},
|
||||
Config: &container.Config{Labels: tc.labels},
|
||||
}
|
||||
target, err := ParseContainer(cJSON, "/hostfs")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, target)
|
||||
assert.Equal(t, tc.want, target.Lock)
|
||||
assert.Equal(t, "myctr", target.Name) // leading slash trimmed
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package lockfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const version = 1
|
||||
|
||||
// Entry is a single container's remembered backup state.
|
||||
type Entry struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LockFile records the resolved host paths backed up for each container, so those paths
|
||||
// keep being backed up even when the container is no longer running.
|
||||
type LockFile struct {
|
||||
Version int `json:"version"`
|
||||
Containers map[string]Entry `json:"containers"`
|
||||
}
|
||||
|
||||
func empty() *LockFile {
|
||||
return &LockFile{Version: version, Containers: map[string]Entry{}}
|
||||
}
|
||||
|
||||
// Load reads the lock file at path. A missing file yields an empty LockFile and a nil
|
||||
// error. A corrupt or unreadable file yields an empty LockFile and a non-nil error, so
|
||||
// the caller can warn and continue rather than lose a backup.
|
||||
func Load(path string) (*LockFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return empty(), nil
|
||||
}
|
||||
return empty(), err
|
||||
}
|
||||
|
||||
var l LockFile
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return empty(), err
|
||||
}
|
||||
if l.Containers == nil {
|
||||
l.Containers = map[string]Entry{}
|
||||
}
|
||||
if l.Version == 0 {
|
||||
l.Version = version
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Upsert records or replaces a container's entry.
|
||||
func (l *LockFile) Upsert(id, name string, paths []string, now time.Time) {
|
||||
l.Containers[id] = Entry{
|
||||
Name: name,
|
||||
Paths: paths,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a container's entry. Deleting a missing id is a no-op.
|
||||
func (l *LockFile) Delete(id string) {
|
||||
delete(l.Containers, id)
|
||||
}
|
||||
|
||||
// BackupPaths returns the deduped, existence-filtered, stably sorted union of every
|
||||
// entry's paths. exists reports whether a host path is still present on disk; paths for
|
||||
// which it returns false are skipped. A nil exists keeps every path.
|
||||
func (l *LockFile) BackupPaths(exists func(string) bool) []string {
|
||||
set := map[string]struct{}{}
|
||||
for _, e := range l.Containers {
|
||||
for _, p := range e.Paths {
|
||||
if exists == nil || exists(p) {
|
||||
set[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
// Save writes the lock file atomically: it marshals to a temp file in the destination
|
||||
// directory and renames it into place, creating the parent directory if needed.
|
||||
func Save(path string, l *LockFile) error {
|
||||
if l.Version == 0 {
|
||||
l.Version = version
|
||||
}
|
||||
if l.Containers == nil {
|
||||
l.Containers = map[string]Entry{}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(l, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".restic-backup-lock-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op once the rename succeeds
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lockfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoad_Missing(t *testing.T) {
|
||||
lf, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json"))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, lf)
|
||||
assert.Equal(t, 1, lf.Version)
|
||||
assert.Empty(t, lf.Containers)
|
||||
}
|
||||
|
||||
func TestLoad_Corrupt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "corrupt.json")
|
||||
assert.NoError(t, os.WriteFile(path, []byte("{not valid json"), 0o644))
|
||||
|
||||
lf, err := Load(path)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, lf)
|
||||
assert.Empty(t, lf.Containers)
|
||||
}
|
||||
|
||||
func TestSaveLoad_RoundTrip(t *testing.T) {
|
||||
// Nested dir that does not exist yet — Save must create it.
|
||||
path := filepath.Join(t.TempDir(), "state", "restic-backup-lock.json")
|
||||
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
lf := empty()
|
||||
lf.Upsert("c1", "pg", []string{"/hostfs/a", "/hostfs/b"}, now)
|
||||
assert.NoError(t, Save(path, lf))
|
||||
|
||||
got, err := Load(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, lf.Version, got.Version)
|
||||
assert.Equal(t, lf.Containers, got.Containers)
|
||||
}
|
||||
|
||||
func TestSave_AtomicLeavesNoTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "restic-backup-lock.json")
|
||||
|
||||
lf := empty()
|
||||
lf.Upsert("c1", "pg", []string{"/x"}, time.Unix(0, 0).UTC())
|
||||
assert.NoError(t, Save(path, lf))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
assert.Equal(t, "restic-backup-lock.json", entries[0].Name())
|
||||
}
|
||||
|
||||
func TestUpsert_Replaces(t *testing.T) {
|
||||
lf := empty()
|
||||
now := time.Unix(0, 0).UTC()
|
||||
lf.Upsert("c1", "old", []string{"/a"}, now)
|
||||
lf.Upsert("c1", "new", []string{"/b"}, now)
|
||||
|
||||
assert.Len(t, lf.Containers, 1)
|
||||
assert.Equal(t, "new", lf.Containers["c1"].Name)
|
||||
assert.Equal(t, []string{"/b"}, lf.Containers["c1"].Paths)
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
lf := empty()
|
||||
lf.Upsert("c1", "", []string{"/a"}, time.Unix(0, 0).UTC())
|
||||
lf.Delete("c1")
|
||||
assert.Empty(t, lf.Containers)
|
||||
lf.Delete("missing") // must not panic
|
||||
}
|
||||
|
||||
func TestBackupPaths_DedupFilterSort(t *testing.T) {
|
||||
lf := empty()
|
||||
now := time.Unix(0, 0).UTC()
|
||||
lf.Upsert("c1", "", []string{"/gone", "/b", "/a"}, now)
|
||||
lf.Upsert("c2", "", []string{"/a", "/c"}, now) // /a duplicated across containers
|
||||
|
||||
exists := func(p string) bool { return p != "/gone" }
|
||||
assert.Equal(t, []string{"/a", "/b", "/c"}, lf.BackupPaths(exists))
|
||||
}
|
||||
|
||||
func TestBackupPaths_NilExistsKeepsAll(t *testing.T) {
|
||||
lf := empty()
|
||||
lf.Upsert("c1", "", []string{"/b", "/a"}, time.Unix(0, 0).UTC())
|
||||
assert.Equal(t, []string{"/a", "/b"}, lf.BackupPaths(nil))
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
type ResticExecutor func(ctx context.Context, args []string) error
|
||||
|
||||
func RunHooksAndBackup(ctx context.Context, client dockerclient.DockerClient, targets []*apptypes.Target, resticArgs []string, execRestic ResticExecutor) error {
|
||||
func RunHooksAndBackup(ctx context.Context, client dockerclient.DockerClient, targets []*apptypes.Target, backupPaths []string, resticArgs []string, execRestic ResticExecutor) error {
|
||||
var executedPreHooks []*apptypes.Target
|
||||
var stoppedContainers []*apptypes.Target
|
||||
|
||||
@@ -53,11 +53,6 @@ func RunHooksAndBackup(ctx context.Context, client dockerclient.DockerClient, ta
|
||||
}
|
||||
}
|
||||
|
||||
var backupPaths []string
|
||||
for _, t := range targets {
|
||||
backupPaths = append(backupPaths, t.Paths...)
|
||||
}
|
||||
|
||||
if len(backupPaths) > 0 {
|
||||
args := append([]string{"backup"}, backupPaths...)
|
||||
args = append(args, resticArgs...)
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestRunHooksAndBackup(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := RunHooksAndBackup(context.Background(), client, targets, []string{"--tag", "test"}, execRestic)
|
||||
err := RunHooksAndBackup(context.Background(), client, targets, []string{"/path1", "/path2"}, []string{"--tag", "test"}, execRestic)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check hooks
|
||||
@@ -96,7 +96,7 @@ func TestRunHooksAndBackup_ResticFailure(t *testing.T) {
|
||||
return fmt.Errorf("restic failed")
|
||||
}
|
||||
|
||||
err := RunHooksAndBackup(context.Background(), client, targets, []string{}, execRestic)
|
||||
err := RunHooksAndBackup(context.Background(), client, targets, []string{"/path1"}, []string{}, execRestic)
|
||||
assert.ErrorContains(t, err, "restic backup failed: restic failed")
|
||||
|
||||
// Check hooks - post hook should still run!
|
||||
|
||||
Reference in new issue
Block a user