Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use source-like var expansion, allow non-root usage #372

Merged
merged 2 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Run Unit Tests

on:
push:
branches:
- main
pull_request:

jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.22.x'
- name: Install dependencies
run: go mod download
- name: Test with the Go CLI
run: go test -v ./...
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ FROM alpine:3.19

WORKDIR /root

RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates && \
chmod a+rw /var/lock

COPY --from=builder /app/cmd/backup/backup /usr/bin/backup

Expand Down
44 changes: 39 additions & 5 deletions cmd/backup/config_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
package main

import (
"bufio"
"fmt"
"os"
"path/filepath"

"github.com/joho/godotenv"
"github.com/offen/docker-volume-backup/internal/errwrap"
"github.com/offen/envconfig"
shell "mvdan.cc/sh/v3/shell"
)

type configStrategy string
Expand Down Expand Up @@ -99,11 +101,7 @@ func loadConfigsFromEnvFiles(directory string) ([]*Config, error) {
continue
}
p := filepath.Join(directory, item.Name())
f, err := os.ReadFile(p)
if err != nil {
return nil, errwrap.Wrap(err, fmt.Sprintf("error reading %s", item.Name()))
}
envFile, err := godotenv.Unmarshal(os.ExpandEnv(string(f)))
envFile, err := source(p)
if err != nil {
return nil, errwrap.Wrap(err, fmt.Sprintf("error reading config file %s", p))
}
Expand All @@ -125,3 +123,39 @@ func loadConfigsFromEnvFiles(directory string) ([]*Config, error) {

return configs, nil
}

// source tries to mimic the pre v2.37.0 behavior of calling
// `set +a; source $path; set -a` and returns the env vars as a map
func source(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, errwrap.Wrap(err, fmt.Sprintf("error opening %s", path))
}

result := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
withExpansion, err := shell.Expand(line, nil)
if err != nil {
return nil, errwrap.Wrap(err, "error expanding env")
}
m, err := godotenv.Unmarshal(withExpansion)
if err != nil {
return nil, errwrap.Wrap(err, fmt.Sprintf("error sourcing %s", path))
}
for key, value := range m {
currentValue, currentOk := os.LookupEnv(key)
defer func() {
if currentOk {
os.Setenv(key, currentValue)
return
}
os.Unsetenv(key)
}()
result[key] = value
os.Setenv(key, value)
}
}
return result, nil
}
68 changes: 68 additions & 0 deletions cmd/backup/config_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"os"
"reflect"
"testing"
)

func TestSource(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
expectedOutput map[string]string
}{
{
"default",
"testdata/default.env",
false,
map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
},
{
"not found",
"testdata/nope.env",
true,
nil,
},
{
"braces",
"testdata/braces.env",
false,
map[string]string{
"FOO": "qux",
"BAR": "xxx",
"BAZ": "",
},
},
{
"expansion",
"testdata/expansion.env",
false,
map[string]string{
"BAR": "xxx",
"FOO": "xxx",
"BAZ": "xxx",
"QUX": "yyy",
},
},
}

os.Setenv("QUX", "yyy")
defer os.Unsetenv("QUX")

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := source(test.input)
if (err != nil) != test.expectError {
t.Errorf("Unexpected error value %v", err)
}
if !reflect.DeepEqual(test.expectedOutput, result) {
t.Errorf("Expected %v, got %v", test.expectedOutput, result)
}
})
}
}
3 changes: 3 additions & 0 deletions cmd/backup/testdata/braces.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FOO=${bar:-qux}
BAR=xxx
BAZ=$NOPE
2 changes: 2 additions & 0 deletions cmd/backup/testdata/default.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FOO=bar
BAZ=qux
4 changes: 4 additions & 0 deletions cmd/backup/testdata/expansion.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
BAR=xxx
FOO=${BAR}
BAZ=$BAR
QUX=${QUX}
2 changes: 1 addition & 1 deletion docs/how-tos/replace-deprecated-backup-from-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Replace deprecated BACKUP_FROM_SNAPSHOT usage
layout: default
parent: How Tos
nav_order: 16
nav_order: 17
---

# Replace deprecated `BACKUP_FROM_SNAPSHOT` usage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Replace deprecated BACKUP_STOP_CONTAINER_LABEL setting
layout: default
parent: How Tos
nav_order: 19
nav_order: 20
---

# Replace deprecated `BACKUP_STOP_CONTAINER_LABEL` setting
Expand Down
2 changes: 1 addition & 1 deletion docs/how-tos/replace-deprecated-exec-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Replace deprecated exec-pre and exec-post labels
layout: default
parent: How Tos
nav_order: 17
nav_order: 18
---

# Replace deprecated `exec-pre` and `exec-post` labels
Expand Down
2 changes: 1 addition & 1 deletion docs/how-tos/update-deprecated-email-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Update deprecated email configuration
layout: default
parent: How Tos
nav_order: 18
nav_order: 19
---

# Update deprecated email configuration
Expand Down
36 changes: 36 additions & 0 deletions docs/how-tos/use-as-non-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: Use the image as a non-root user
layout: default
parent: How Tos
nav_order: 16
---

# Use the image as a non-root user

{: .important }
Running as a non-root user limits interaction with the Docker Daemon.
If you want to stop and restart containers and services during backup, and the host's Docker daemon is running as root, you will also need to run this tool as root.

By default, this image executes backups using the `root` user.
In case you prefer to use a different user, you can use Docker's [`user`](https://docs.docker.com/engine/reference/run/#user) option, passing the user and group id:

```console
docker run --rm \
-v data:/backup/data \
--env AWS_ACCESS_KEY_ID="<xxx>" \
--env AWS_SECRET_ACCESS_KEY="<xxx>" \
--env AWS_S3_BUCKET_NAME="<xxx>" \
--entrypoint backup \
--user 1000:1000 \
offen/docker-volume-backup:v2
```

or in a compose file:

```yml
services:
backup:
image: offen/docker-volume-backup:v2
user: 1000:1000
# further configuration omitted ...
```
21 changes: 21 additions & 0 deletions docs/recipes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,24 @@ volumes:
data_1:
data_2:
```

## Running as a non-root user

```yml
version: '3'

services:
# ... define other services using the `data` volume here
backup:
image: offen/docker-volume-backup:v2
user: 1000:1000
environment:
AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
volumes:
- data:/backup/my-app-backup:ro

volumes:
data:
```
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ require (
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
mvdan.cc/sh/v3 v3.8.0 // indirect
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
mvdan.cc/sh/v3 v3.8.0 h1:ZxuJipLZwr/HLbASonmXtcvvC9HXY9d2lXZHnKGjFc8=
mvdan.cc/sh/v3 v3.8.0/go.mod h1:w04623xkgBVo7/IUK89E0g8hBykgEpN0vgOj3RJr6MY=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
Expand Down
1 change: 1 addition & 0 deletions test/confd/02backup.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NAME="other"
BACKUP_CRON_EXPRESSION="*/1 * * * *"
BACKUP_FILENAME="override-$NAME.tar.gz"
2 changes: 1 addition & 1 deletion test/confd/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ if [ ! -f "$LOCAL_DIR/conf.tar.gz" ]; then
fi
pass "Config from file was used."

if [ ! -f "$LOCAL_DIR/other.tar.gz" ]; then
if [ ! -f "$LOCAL_DIR/override-other.tar.gz" ]; then
fail "Run on same schedule did not succeed."
fi
pass "Run on same schedule succeeded."
Expand Down
7 changes: 7 additions & 0 deletions test/nonroot/01conf.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
AWS_ACCESS_KEY_ID="test"
AWS_SECRET_ACCESS_KEY="GMusLtUmILge2by+z890kQ"
AWS_ENDPOINT="minio:9000"
AWS_ENDPOINT_PROTO="http"
AWS_S3_BUCKET_NAME="backup"
BACKUP_CRON_EXPRESSION="0 0 5 31 2 ?"
BACKUP_FILENAME="test.tar.gz"
33 changes: 33 additions & 0 deletions test/nonroot/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
version: '3'

services:
minio:
image: minio/minio:RELEASE.2020-08-04T23-10-51Z
environment:
MINIO_ROOT_USER: test
MINIO_ROOT_PASSWORD: test
MINIO_ACCESS_KEY: test
MINIO_SECRET_KEY: GMusLtUmILge2by+z890kQ
entrypoint: /bin/ash -c 'mkdir -p /data/backup && minio server /data'
volumes:
- ${LOCAL_DIR:-local}:/data

backup:
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
user: 1000:1000
depends_on:
- minio
restart: always
volumes:
- app_data:/backup/app_data:ro
- ./01conf.env:/etc/dockervolumebackup/conf.d/01conf.env

offen:
image: offen/offen:latest
labels:
- docker-volume-backup.stop-during-backup=true
volumes:
- app_data:/var/opt/offen

volumes:
app_data:
27 changes: 27 additions & 0 deletions test/nonroot/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/bin/sh

set -e

cd "$(dirname "$0")"
. ../util.sh
current_test=$(basename $(pwd))

export LOCAL_DIR=$(mktemp -d)

docker compose up -d --quiet-pull
sleep 5

docker compose logs backup

# conf.d is used to confirm /etc files are also accessible for non-root users
docker compose exec backup /bin/sh -c 'set -a; source /etc/dockervolumebackup/conf.d/01conf.env; set +a && backup'

sleep 5

expect_running_containers "3"

if [ ! -f "$LOCAL_DIR/backup/test.tar.gz" ]; then
fail "Could not find archive."
fi
pass "Archive was created."

2 changes: 1 addition & 1 deletion test/util.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ skip () {

expect_running_containers () {
if [ "$(docker ps -q | wc -l)" != "$1" ]; then
fail "Expected $1 containers to be running, instead seen: "$(docker ps -a | wc -l)""
fail "Expected $1 containers to be running, instead seen: "$(docker ps -q | wc -l)""
fi
pass "$1 containers running."
}
Expand Down
Loading