mirror of
https://github.com/offen/docker-volume-backup.git
synced 2025-12-05 17:18:02 +01:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52b271bac | ||
|
|
cac5777e79 | ||
|
|
94a1edc4ad | ||
|
|
a654097e59 | ||
|
|
1b1fc4856c | ||
|
|
e81c34b8fc | ||
|
|
9c23767fce | ||
|
|
51af8c3c77 | ||
|
|
1ea0b51b23 | ||
|
|
da8c63f755 | ||
|
|
9bc8db0f7c | ||
|
|
508bc07b4f | ||
|
|
b8f71b04a1 | ||
|
|
5f3832d621 | ||
|
|
4b1127b8c4 | ||
|
|
ae50a3ac4f | ||
|
|
bad22eee93 |
@@ -48,6 +48,7 @@ jobs:
|
||||
if [[ "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
# prerelease tags like `v2.0.0-alpha.1` should not be released as `latest`
|
||||
tag_args="$tag_args -t offen/docker-volume-backup:latest"
|
||||
tag_args="$tag_args -t offen/docker-volume-backup:$(echo "$CIRCLE_TAG" | cut -d. -f1)"
|
||||
fi
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
$tag_args . --push
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright 2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM golang:1.17-alpine as builder
|
||||
FROM golang:1.18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
163
README.md
163
README.md
@@ -27,8 +27,10 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
|
||||
- [Using with Docker Swarm](#using-with-docker-swarm)
|
||||
- [Manually triggering a backup](#manually-triggering-a-backup)
|
||||
- [Update deprecated email configuration](#update-deprecated-email-configuration)
|
||||
- [Replace deprecated `BACKUP_FROM_SNAPSHOT` usage](#replace-deprecated-backup_from_snapshot-usage)
|
||||
- [Using a custom Docker host](#using-a-custom-docker-host)
|
||||
- [Run multiple backup schedules in the same container](#run-multiple-backup-schedules-in-the-same-container)
|
||||
- [Define different retention schedules](#define-different-retention-schedules)
|
||||
- [Recipes](#recipes)
|
||||
- [Backing up to AWS S3](#backing-up-to-aws-s3)
|
||||
- [Backing up to Filebase](#backing-up-to-filebase)
|
||||
@@ -107,7 +109,7 @@ docker run --rm \
|
||||
--env AWS_SECRET_ACCESS_KEY="<xxx>" \
|
||||
--env AWS_S3_BUCKET_NAME="<xxx>" \
|
||||
--entrypoint backup \
|
||||
offen/docker-volume-backup:latest
|
||||
offen/docker-volume-backup:v2
|
||||
```
|
||||
|
||||
Alternatively, pass a `--env-file` in order to use a full config as described below.
|
||||
@@ -149,6 +151,11 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# BACKUP_LATEST_SYMLINK="backup.latest.tar.gz"
|
||||
|
||||
# ************************************************************************
|
||||
# The BACKUP_FROM_SNAPSHOT option has been deprecated and will be removed
|
||||
# in the next major version. Please use exec-pre and exec-post
|
||||
# as documented below instead.
|
||||
# ************************************************************************
|
||||
# Whether to copy the content of backup folder before creating the tar archive.
|
||||
# In the rare scenario where the content of the source backup volume is continously
|
||||
# updating, but we do not wish to stop the container while performing the backup,
|
||||
@@ -156,6 +163,17 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# BACKUP_FROM_SNAPSHOT="false"
|
||||
|
||||
# By default, the `/backup` directory inside the container will be backed up.
|
||||
# In case you need to use a custom location, set `BACKUP_SOURCES`.
|
||||
|
||||
# BACKUP_SOURCES="/other/location"
|
||||
|
||||
# When given, all files in BACKUP_SOURCES whose full path matches the given
|
||||
# regular expression will be excluded from the archive. Regular Expressions
|
||||
# can be used as from the Go standard library https://pkg.go.dev/regexp
|
||||
|
||||
# BACKUP_EXCLUDE_REGEXP="\.log$"
|
||||
|
||||
########### BACKUP STORAGE
|
||||
|
||||
# The name of the remote bucket that should be used for storing backups. If
|
||||
@@ -196,9 +214,9 @@ You can populate below template according to your requirements and use it as you
|
||||
# AWS_ENDPOINT_PROTO="https"
|
||||
|
||||
# Setting this variable to `true` will disable verification of
|
||||
# SSL certificates. You shouldn't use this unless you use self-signed
|
||||
# certificates for your remote storage backend. This can only be used
|
||||
# when AWS_ENDPOINT_PROTO is set to `https`.
|
||||
# SSL certificates for AWS_ENDPOINT. You shouldn't use this unless you use
|
||||
# self-signed certificates for your remote storage backend. This can only be
|
||||
# used when AWS_ENDPOINT_PROTO is set to `https`.
|
||||
|
||||
# AWS_ENDPOINT_INSECURE="true"
|
||||
|
||||
@@ -221,6 +239,12 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# WEBDAV_PASSWORD="password"
|
||||
|
||||
# Setting this variable to `true` will disable verification of
|
||||
# SSL certificates for WEBDAV_URL. You shouldn't use this unless you use
|
||||
# self-signed certificates for your remote storage backend.
|
||||
|
||||
# WEBDAV_URL_INSECURE="true"
|
||||
|
||||
# In addition to storing backups remotely, you can also keep local copies.
|
||||
# Pass a container-local path to store your backups if needed. You also need to
|
||||
# mount a local folder or Docker volume into that location (`/archive`
|
||||
@@ -330,6 +354,16 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# DOCKER_HOST="tcp://docker_socket_proxy:2375"
|
||||
|
||||
########### LOCK_TIMEOUT
|
||||
|
||||
# In the case of overlapping cron schedules run by the same container,
|
||||
# subsequent invocations will wait for previous runs to finish before starting.
|
||||
# By default, this will time out and fail in case the lock could not be acquired
|
||||
# after 60 minutes. In case you need to adjust this timeout, supply a duration
|
||||
# value as per https://pkg.go.dev/time#ParseDuration to `LOCK_TIMEOUT`
|
||||
|
||||
# LOCK_TIMEOUT="60m"
|
||||
|
||||
########### EMAIL NOTIFICATIONS
|
||||
|
||||
# ************************************************************************
|
||||
@@ -386,7 +420,7 @@ services:
|
||||
- docker-volume-backup.stop-during-backup=service1
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
BACKUP_STOP_CONTAINER_LABEL: service1
|
||||
volumes:
|
||||
@@ -409,7 +443,7 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
|
||||
BACKUP_PRUNING_PREFIX: backup-
|
||||
@@ -432,7 +466,7 @@ version: '3'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
# ... other configuration values go here
|
||||
NOTIFICATION_URLS=smtp://me:secret@smtp.example.com:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com
|
||||
@@ -510,7 +544,7 @@ services:
|
||||
- docker-volume-backup.exec-label=database
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
EXEC_LABEL: database
|
||||
volumes:
|
||||
@@ -587,7 +621,7 @@ version: '3'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
@@ -610,7 +644,7 @@ When running in Swarm mode, it's also advised to set a hard memory limit on your
|
||||
```yml
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
deployment:
|
||||
resources:
|
||||
limits:
|
||||
@@ -645,6 +679,37 @@ After:
|
||||
NOTIFICATION_URLS=smtp://me:secret@posteo.de:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com
|
||||
```
|
||||
|
||||
### Replace deprecated `BACKUP_FROM_SNAPSHOT` usage
|
||||
|
||||
Starting with version 2.15.0, the `BACKUP_FROM_SNAPSHOT` feature has been deprecated.
|
||||
If you need to prepare your sources before the backup is taken, use `exec-pre`, `exec-post` and an intermediate volume:
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
my_app:
|
||||
build: .
|
||||
volumes:
|
||||
- data:/var/my_app
|
||||
- backup:/tmp/backup
|
||||
labels:
|
||||
- docker-volume-backup.exec-pre=cp -r /var/my_app /tmp/backup/my-app
|
||||
- docker-volume-backup.exec-post=rm -rf /tmp/backup/my-app
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
environment:
|
||||
BACKUP_SOURCES: /tmp/backup
|
||||
volumes:
|
||||
- backup:/backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
data:
|
||||
backup:
|
||||
```
|
||||
|
||||
### Using a custom Docker host
|
||||
|
||||
If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL.
|
||||
@@ -658,13 +723,13 @@ In case you are using a socket proxy, it must support `GET` and `POST` requests
|
||||
|
||||
Multiple backup schedules with different configuration can be configured by mounting an arbitrary number of configuration files (using the `.env` format) into `/etc/dockervolumebackup/conf.d`:
|
||||
|
||||
```
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -676,10 +741,44 @@ volumes:
|
||||
|
||||
A separate cronjob will be created for each config file.
|
||||
If a configuration value is set both in the global environment as well as in the config file, the config file will take precedence.
|
||||
The `backup` command expects to run on an exclusive lock, so it is your responsibility to make sure the invocations do not overlap.
|
||||
The `backup` command expects to run on an exclusive lock, so in case you provide the same or overlapping schedules in your cron expressions, the runs will still be executed serially, one after the other.
|
||||
The exact order of schedules that use the same cron expression is not specified.
|
||||
In case you need your schedules to overlap, you need to create a dedicated container for each schedule instead.
|
||||
When changing the configuration, you currently need to manually restart the container for the changes to take effect.
|
||||
|
||||
### Define different retention schedules
|
||||
|
||||
If you want to manage backup retention on different schedules, the most straight forward approach is to define a dedicated configuration for retention rule using a different prefix in the `BACKUP_FILENAME` parameter and then run them on different cron schedules.
|
||||
|
||||
For example, if you wanted to keep daily backups for 7 days, weekly backups for a month, and retain monthly backups forever, you could create three configuration files and mount them into `/etc/dockervolumebackup.d`:
|
||||
|
||||
```ini
|
||||
# 01daily.conf
|
||||
BACKUP_FILENAME="daily-backup-%Y-%m-%dT%H-%M-%S.tar.gz"
|
||||
# run every day at 2am
|
||||
BACKUP_CRON_EXPRESSION="0 2 * * *"
|
||||
BACKUP_PRUNING_PREFIX="daily-backup-"
|
||||
BACKUP_RETENTION_DAYS="7"
|
||||
```
|
||||
|
||||
```ini
|
||||
# 02weekly.conf
|
||||
BACKUP_FILENAME="weekly-backup-%Y-%m-%dT%H-%M-%S.tar.gz"
|
||||
# run every monday at 3am
|
||||
BACKUP_CRON_EXPRESSION="0 3 * * 1"
|
||||
BACKUP_PRUNING_PREFIX="weekly-backup-"
|
||||
BACKUP_RETENTION_DAYS="31"
|
||||
```
|
||||
|
||||
```ini
|
||||
# 03monthly.conf
|
||||
BACKUP_FILENAME="monthly-backup-%Y-%m-%dT%H-%M-%S.tar.gz"
|
||||
# run every 1st of a month at 4am
|
||||
BACKUP_CRON_EXPRESSION="0 4 1 * *"
|
||||
```
|
||||
|
||||
Note that while it's possible to define colliding cron schedules for each of these configurations, you might need to adjust the value for `LOCK_TIMEOUT` in case your backups are large and might take longer than an hour.
|
||||
|
||||
## Recipes
|
||||
|
||||
This section lists configuration for some real-world use cases that you can mix and match according to your needs.
|
||||
@@ -692,9 +791,9 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
volumes:
|
||||
@@ -713,10 +812,10 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_ENDPOINT: s3.filebase.com
|
||||
AWS_BUCKET_NAME: filebase-bucket
|
||||
AWS_S3_BUCKET_NAME: filebase-bucket
|
||||
AWS_ACCESS_KEY_ID: FILEBASE-ACCESS-KEY
|
||||
AWS_SECRET_ACCESS_KEY: FILEBASE-SECRET-KEY
|
||||
volumes:
|
||||
@@ -735,10 +834,10 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_ENDPOINT: minio.example.com
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: MINIOACCESSKEY
|
||||
AWS_SECRET_ACCESS_KEY: MINIOSECRETKEY
|
||||
volumes:
|
||||
@@ -757,7 +856,7 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
WEBDAV_URL: https://webdav.mydomain.me
|
||||
WEBDAV_PATH: /my/directory/
|
||||
@@ -779,7 +878,7 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
|
||||
BACKUP_LATEST_SYMLINK: backup-latest.tar.gz
|
||||
@@ -800,9 +899,9 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
volumes:
|
||||
@@ -822,11 +921,11 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
# take a backup on every hour
|
||||
BACKUP_CRON_EXPRESSION: "0 * * * *"
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
volumes:
|
||||
@@ -845,9 +944,9 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
|
||||
@@ -869,9 +968,9 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
GPG_PASSPHRASE: somesecretstring
|
||||
@@ -896,7 +995,7 @@ services:
|
||||
volumes:
|
||||
- app_data:/tmp/dumps
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
BACKUP_FILENAME: db.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: "0 2 * * *"
|
||||
@@ -917,10 +1016,10 @@ version: '3'
|
||||
services:
|
||||
# ... define other services using the `data_1` and `data_2` volumes here
|
||||
backup_1: &backup_service
|
||||
image: offen/docker-volume-backup:latest
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment: &backup_environment
|
||||
BACKUP_CRON_EXPRESSION: "0 2 * * *"
|
||||
AWS_BUCKET_NAME: backup-bucket
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
# Label the container using the `data_1` volume as `docker-volume-backup.stop-during-backup=service1`
|
||||
|
||||
@@ -11,14 +11,13 @@ import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func createArchive(inputFilePath, outputFilePath string) error {
|
||||
func createArchive(files []string, inputFilePath, outputFilePath string) error {
|
||||
inputFilePath = stripTrailingSlashes(inputFilePath)
|
||||
inputFilePath, outputFilePath, err := makeAbsolute(inputFilePath, outputFilePath)
|
||||
if err != nil {
|
||||
@@ -28,7 +27,7 @@ func createArchive(inputFilePath, outputFilePath string) error {
|
||||
return fmt.Errorf("createArchive: error creating output file path: %w", err)
|
||||
}
|
||||
|
||||
if err := compress(inputFilePath, outputFilePath, filepath.Dir(inputFilePath)); err != nil {
|
||||
if err := compress(files, outputFilePath, filepath.Dir(inputFilePath)); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating archive: %w", err)
|
||||
}
|
||||
|
||||
@@ -52,7 +51,7 @@ func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error)
|
||||
return inputFilePath, outputFilePath, err
|
||||
}
|
||||
|
||||
func compress(inPath, outFilePath, subPath string) error {
|
||||
func compress(paths []string, outFilePath, subPath string) error {
|
||||
file, err := os.Create(outFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error creating out file: %w", err)
|
||||
@@ -62,14 +61,6 @@ func compress(inPath, outFilePath, subPath string) error {
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
|
||||
var paths []string
|
||||
if err := filepath.WalkDir(inPath, func(path string, di fs.DirEntry, err error) error {
|
||||
paths = append(paths, path)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("compress: error walking filesystem tree: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
if err := writeTarGz(p, tarWriter, prefix); err != nil {
|
||||
return fmt.Errorf("compress error writing %s to archive: %w", p, err)
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all configuration values that are expected to be set
|
||||
// by users.
|
||||
@@ -18,6 +22,7 @@ type Config struct {
|
||||
BackupPruningPrefix string `split_words:"true"`
|
||||
BackupStopContainerLabel string `split_words:"true" default:"true"`
|
||||
BackupFromSnapshot bool `split_words:"true"`
|
||||
BackupExcludeRegexp RegexpDecoder `split_words:"true"`
|
||||
AwsS3BucketName string `split_words:"true"`
|
||||
AwsS3Path string `split_words:"true"`
|
||||
AwsEndpoint string `split_words:"true" default:"s3.amazonaws.com"`
|
||||
@@ -36,9 +41,27 @@ type Config struct {
|
||||
EmailSMTPUsername string `envconfig:"EMAIL_SMTP_USERNAME"`
|
||||
EmailSMTPPassword string `envconfig:"EMAIL_SMTP_PASSWORD"`
|
||||
WebdavUrl string `split_words:"true"`
|
||||
WebdavUrlInsecure bool `split_words:"true"`
|
||||
WebdavPath string `split_words:"true" default:"/"`
|
||||
WebdavUsername string `split_words:"true"`
|
||||
WebdavPassword string `split_words:"true"`
|
||||
ExecLabel string `split_words:"true"`
|
||||
ExecForwardOutput bool `split_words:"true"`
|
||||
LockTimeout time.Duration `split_words:"true" default:"60m"`
|
||||
}
|
||||
|
||||
type RegexpDecoder struct {
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (r *RegexpDecoder) Decode(v string) error {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp.Compile(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: error compiling given regexp `%s`: %w", v, err)
|
||||
}
|
||||
*r = RegexpDecoder{Re: re}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright 2022 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Portions of this file are taken and adapted from `moby`, Copyright 2012-2017 Docker, Inc.
|
||||
// Licensed under the Apache 2.0 License: https://github.com/moby/moby/blob/8e610b2b55bfd1bfa9436ab110d311f5e8a74dcb/LICENSE
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,12 +13,12 @@ import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cosiner/argv"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (s *script) exec(containerRef string, command string) ([]byte, []byte, error) {
|
||||
@@ -94,29 +97,27 @@ func (s *script) runLabeledCommands(label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(containersWithCommand))
|
||||
g := new(errgroup.Group)
|
||||
|
||||
var cmdErrors []error
|
||||
for _, container := range containersWithCommand {
|
||||
go func(c types.Container) {
|
||||
c := container
|
||||
g.Go(func() error {
|
||||
cmd, _ := c.Labels[label]
|
||||
s.logger.Infof("Running %s command %s for container %s", label, cmd, strings.TrimPrefix(c.Names[0], "/"))
|
||||
stdout, stderr, err := s.exec(c.ID, cmd)
|
||||
if err != nil {
|
||||
cmdErrors = append(cmdErrors, err)
|
||||
}
|
||||
if s.c.ExecForwardOutput {
|
||||
os.Stderr.Write(stderr)
|
||||
os.Stdout.Write(stdout)
|
||||
}
|
||||
wg.Done()
|
||||
}(container)
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error executing command: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if len(cmdErrors) != 0 {
|
||||
return join(cmdErrors...)
|
||||
if err := g.Wait(); err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error from errgroup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
58
cmd/backup/lock.go
Normal file
58
cmd/backup/lock.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright 2022 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
// lock opens a lockfile at the given location, keeping it locked until the
|
||||
// caller invokes the returned release func. In case the lock is currently blocked
|
||||
// by another execution, it will repeatedly retry until the lock is available
|
||||
// or the given timeout is exceeded.
|
||||
func (s *script) lock(lockfile string) (func() error, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
s.stats.LockedTime = time.Now().Sub(start)
|
||||
}()
|
||||
|
||||
retry := time.NewTicker(5 * time.Second)
|
||||
defer retry.Stop()
|
||||
deadline := time.NewTimer(s.c.LockTimeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
fileLock := flock.New(lockfile)
|
||||
|
||||
for {
|
||||
acquired, err := fileLock.TryLock()
|
||||
if err != nil {
|
||||
return noop, fmt.Errorf("lock: error trying lock: %w", err)
|
||||
}
|
||||
if acquired {
|
||||
if s.encounteredLock {
|
||||
s.logger.Info("Acquired exclusive lock on subsequent attempt, ready to continue.")
|
||||
}
|
||||
return fileLock.Unlock, nil
|
||||
}
|
||||
|
||||
if !s.encounteredLock {
|
||||
s.logger.Infof(
|
||||
"Exclusive lock was not available on first attempt. Will retry until it becomes available or the timeout of %s is exceeded.",
|
||||
s.c.LockTimeout,
|
||||
)
|
||||
s.encounteredLock = true
|
||||
}
|
||||
|
||||
select {
|
||||
case <-retry.C:
|
||||
continue
|
||||
case <-deadline.C:
|
||||
return noop, errors.New("lock: timed out waiting for lockfile to become available")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
unlock := lock("/var/lock/dockervolumebackup.lock")
|
||||
defer unlock()
|
||||
|
||||
s, err := newScript()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
unlock, err := s.lock("/var/lock/dockervolumebackup.lock")
|
||||
defer unlock()
|
||||
s.must(err)
|
||||
|
||||
defer func() {
|
||||
if pArg := recover(); pArg != nil {
|
||||
if err, ok := pArg.(error); ok {
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -46,6 +48,8 @@ type script struct {
|
||||
file string
|
||||
stats *Stats
|
||||
|
||||
encounteredLock bool
|
||||
|
||||
c *Config
|
||||
}
|
||||
|
||||
@@ -143,6 +147,15 @@ func newScript() (*script, error) {
|
||||
} else {
|
||||
webdavClient := gowebdav.NewClient(s.c.WebdavUrl, s.c.WebdavUsername, s.c.WebdavPassword)
|
||||
s.webdavClient = webdavClient
|
||||
if s.c.WebdavUrlInsecure {
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("newScript: unexpected error when asserting type for http.DefaultTransport")
|
||||
}
|
||||
webdavTransport := defaultTransport.Clone()
|
||||
webdavTransport.TLSClientConfig.InsecureSkipVerify = s.c.WebdavUrlInsecure
|
||||
s.webdavClient.SetTransport(webdavTransport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +366,12 @@ func (s *script) takeBackup() error {
|
||||
backupSources := s.c.BackupSources
|
||||
|
||||
if s.c.BackupFromSnapshot {
|
||||
s.logger.Warn(
|
||||
"Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.",
|
||||
)
|
||||
s.logger.Warn(
|
||||
"Please use `exec-pre` and `exec-post` commands to prepare your backup sources. Refer to the README for an upgrade guide.",
|
||||
)
|
||||
backupSources = filepath.Join("/tmp", s.c.BackupSources)
|
||||
// copy before compressing guard against a situation where backup folder's content are still growing.
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
@@ -379,7 +398,28 @@ func (s *script) takeBackup() error {
|
||||
s.logger.Infof("Removed tar file `%s`.", tarFile)
|
||||
return nil
|
||||
})
|
||||
if err := createArchive(backupSources, tarFile); err != nil {
|
||||
|
||||
backupPath, err := filepath.Abs(stripTrailingSlashes(backupSources))
|
||||
if err != nil {
|
||||
return fmt.Errorf("takeBackup: error getting absolute path: %w", err)
|
||||
}
|
||||
|
||||
var filesEligibleForBackup []string
|
||||
if err := filepath.WalkDir(backupPath, func(path string, di fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.c.BackupExcludeRegexp.Re != nil && s.c.BackupExcludeRegexp.Re.MatchString(path) {
|
||||
return nil
|
||||
}
|
||||
filesEligibleForBackup = append(filesEligibleForBackup, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("compress: error walking filesystem tree: %w", err)
|
||||
}
|
||||
|
||||
if err := createArchive(filesEligibleForBackup, backupSources, tarFile); err != nil {
|
||||
return fmt.Errorf("takeBackup: error compressing backup folder: %w", err)
|
||||
}
|
||||
|
||||
@@ -527,7 +567,8 @@ func (s *script) pruneBackups() error {
|
||||
if s.minioClient != nil {
|
||||
candidates := s.minioClient.ListObjects(context.Background(), s.c.AwsS3BucketName, minio.ListObjectsOptions{
|
||||
WithMetadata: true,
|
||||
Prefix: s.c.BackupPruningPrefix,
|
||||
Prefix: filepath.Join(s.c.AwsS3Path, s.c.BackupPruningPrefix),
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
var matches []minio.ObjectInfo
|
||||
@@ -580,6 +621,9 @@ func (s *script) pruneBackups() error {
|
||||
var matches []fs.FileInfo
|
||||
var lenCandidates int
|
||||
for _, candidate := range candidates {
|
||||
if !strings.HasPrefix(candidate.Name(), s.c.BackupPruningPrefix) {
|
||||
continue
|
||||
}
|
||||
lenCandidates++
|
||||
if candidate.ModTime().Before(deadline) {
|
||||
matches = append(matches, candidate)
|
||||
|
||||
@@ -42,6 +42,7 @@ type Stats struct {
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
TookTime time.Duration
|
||||
LockedTime time.Duration
|
||||
LogOutput *bytes.Buffer
|
||||
Containers ContainersStats
|
||||
BackupFile BackupFileStats
|
||||
|
||||
@@ -10,27 +10,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
var noop = func() error { return nil }
|
||||
|
||||
// lock opens a lockfile at the given location, keeping it locked until the
|
||||
// caller invokes the returned release func. When invoked while the file is
|
||||
// still locked the function panics.
|
||||
func lock(lockfile string) func() error {
|
||||
fileLock := flock.New(lockfile)
|
||||
acquired, err := fileLock.TryLock()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !acquired {
|
||||
panic("unable to acquire file lock")
|
||||
}
|
||||
return fileLock.Unlock
|
||||
}
|
||||
|
||||
// copy creates a copy of the file located at `dst` at `src`.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
|
||||
@@ -13,6 +13,7 @@ Here is a list of all data passed to the template:
|
||||
* `StartTime`: time when the script started execution
|
||||
* `EndTime`: time when the backup has completed successfully (after pruning)
|
||||
* `TookTime`: amount of time it took for the backup to run. (equal to `EndTime - StartTime`)
|
||||
* `LockedTime`: amount of time it took for the backup to acquire the exclusive lock
|
||||
* `LogOutput`: full log of the application
|
||||
* `Containers`: object containing stats about the docker containers
|
||||
* `All`: total number of containers
|
||||
|
||||
5
go.mod
5
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/offen/docker-volume-backup
|
||||
|
||||
go 1.17
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/containrrr/shoutrrr v0.5.2
|
||||
@@ -12,8 +12,9 @@ require (
|
||||
github.com/minio/minio-go/v7 v7.0.16
|
||||
github.com/otiai10/copy v1.7.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211109083228-3f8721cd4b6f
|
||||
github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
5
go.sum
5
go.sum
@@ -430,7 +430,6 @@ github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
|
||||
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
|
||||
github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -661,6 +660,8 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211109083228-3f8721cd4b6f h1:L2NE7BXnSlSLoNYZ0lCwZDjdnYjCNYC71k9ClZUTFTs=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211109083228-3f8721cd4b6f/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62 h1:b2nJXyPCa9HY7giGM+kYcnQ71m14JnGdQabMPmyt++8=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
|
||||
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
|
||||
@@ -806,6 +807,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
BACKUP_PRUNING_PREFIX: test
|
||||
GPG_PASSPHRASE: 1234secret
|
||||
WEBDAV_URL: http://webdav/
|
||||
WEBDAV_URL_INSECURE: 'true'
|
||||
WEBDAV_PATH: /my/new/path/
|
||||
WEBDAV_USERNAME: test
|
||||
WEBDAV_PASSWORD: test
|
||||
|
||||
2
test/confd/02backup.env
Normal file
2
test/confd/02backup.env
Normal file
@@ -0,0 +1,2 @@
|
||||
BACKUP_FILENAME="other.tar.gz"
|
||||
BACKUP_CRON_EXPRESSION="*/1 * * * *"
|
||||
@@ -7,8 +7,9 @@ services:
|
||||
volumes:
|
||||
- ./local:/archive
|
||||
- app_data:/backup/app_data:ro
|
||||
- ./backup.env:/etc/dockervolumebackup/conf.d/00backup.env
|
||||
- ./never.env:/etc/dockervolumebackup/conf.d/10never.env
|
||||
- ./01backup.env:/etc/dockervolumebackup/conf.d/01backup.env
|
||||
- ./02backup.env:/etc/dockervolumebackup/conf.d/02backup.env
|
||||
- ./03never.env:/etc/dockervolumebackup/conf.d/03never.env
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
offen:
|
||||
|
||||
@@ -19,6 +19,12 @@ if [ ! -f ./local/conf.tar.gz ]; then
|
||||
fi
|
||||
echo "[TEST:PASS] Config from file was used."
|
||||
|
||||
if [ ! -f ./local/other.tar.gz ]; then
|
||||
echo "[TEST:FAIL] Run on same schedule did not succeed."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Run on same schedule succeeded."
|
||||
|
||||
if [ -f ./local/never.tar.gz ]; then
|
||||
echo "[TEST:FAIL] Unexpected file was found."
|
||||
exit 1
|
||||
|
||||
1
test/ignore/.gitignore
vendored
Normal file
1
test/ignore/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
15
test/ignore/docker-compose.yml
Normal file
15
test/ignore/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
BACKUP_EXCLUDE_REGEXP: '\.(me|you)$$'
|
||||
volumes:
|
||||
- ./local:/archive
|
||||
- ./sources:/backup/data:ro
|
||||
27
test/ignore/run.sh
Normal file
27
test/ignore/run.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
mkdir -p local
|
||||
|
||||
docker-compose up -d
|
||||
sleep 5
|
||||
docker-compose exec backup backup
|
||||
|
||||
docker-compose down --volumes
|
||||
|
||||
out=$(mktemp -d)
|
||||
sudo tar --same-owner -xvf ./local/test.tar.gz -C "$out"
|
||||
|
||||
if [ ! -f "$out/backup/data/me.txt" ]; then
|
||||
echo "[TEST:FAIL] Expected file was not found."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Expected file was found."
|
||||
|
||||
if [ -f "$out/backup/data/skip.me" ]; then
|
||||
echo "[TEST:FAIL] Ignored file was found."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Ignored file was not found."
|
||||
0
test/ignore/sources/me.txt
Normal file
0
test/ignore/sources/me.txt
Normal file
0
test/ignore/sources/skip.me
Normal file
0
test/ignore/sources/skip.me
Normal file
@@ -43,6 +43,8 @@ services:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
healthcheck:
|
||||
disable: true
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
|
||||
Reference in New Issue
Block a user