Skip to content
Open
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
31 changes: 31 additions & 0 deletions redis/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
GO = go
EXT =
PLAKAR ?= plakar
VERSION ?= v1.0.0
GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
PTAR := redis_$(VERSION)_$(GOOS)_$(GOARCH).ptar

all: build

build:
${GO} build -v -o redisImporter${EXT} ./plugin/redis-importer
${GO} build -v -o redisExporter${EXT} ./plugin/redis-exporter

package: build
rm -f $(PTAR)
$(PLAKAR) pkg create ./manifest.yaml $(VERSION)

uninstall:
-$(PLAKAR) pkg rm redis

install: package
$(PLAKAR) pkg add ./$(PTAR)

reinstall: uninstall install

test:
${GO} test -v ./...

clean:
rm -f redisImporter redisExporter
73 changes: 73 additions & 0 deletions redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Redis Integration for Plakar

Back up Redis persistent data as an RDB file and restore that file to disk for Redis startup.

## What it does

- Connects to a local or remote Redis instance with `redis-cli`.
- Optionally triggers `BGSAVE` and waits for the background save to finish.
- Captures the resulting `dump.rdb` into a Plakar snapshot as `/dump.rdb`.
- Restores `/dump.rdb` back to a file path that Redis can use on startup.

This integration is intended for deployments where Redis is used as a primary data store with persistence enabled, not just as a disposable cache.

## Prerequisites

- Plakar ≥ 1.1.
- `redis-cli` available in `$PATH`, or set `redis_bin_dir` / `redis_cli`.
- A Redis user allowed to run `PING`, `BGSAVE`, `INFO persistence`, and `CONFIG GET dir/dbfilename`.
- Filesystem access to the Redis RDB file when backing up local Redis. If the RDB file is not locally readable, the importer falls back to `redis-cli --rdb -`.

## Back up

```sh
plakar source add redis redis://:secret@127.0.0.1:6379/0
plakar backup @redis
```

TLS connections can use `rediss://` or `tls=true`:

```sh
plakar source add redis rediss://default:secret@redis.example.com:6379/0
```

## Restore

Restore writes the RDB file to disk. Stop Redis first, restore to its configured `dbfilename` under `dir`, fix ownership if needed, then start Redis.

```sh
plakar restore -to redis-file:///var/lib/redis/dump.rdb <snapshot-id>
```

Use `force=true` to overwrite an existing file:

```sh
plakar restore -to redis-file:///var/lib/redis/dump.rdb -o force=true <snapshot-id>
```

## Importer options

| Option | Default | Description |
| --- | --- | --- |
| `location` | — | `redis://` or `rediss://` URI |
| `host` | `127.0.0.1` | Redis host |
| `port` | `6379` | Redis port |
| `username` | — | ACL username |
| `password` | — | Password, passed via `REDISCLI_AUTH` |
| `database` | URI path | Logical database number for command context |
| `tls` | `false` | Enable TLS |
| `insecure_tls` | `false` | Skip TLS verification for redis-cli |
| `ca_cert`, `cert`, `key` | — | redis-cli TLS certificate flags |
| `redis_bin_dir` | — | Directory containing `redis-cli` |
| `redis_cli` | `redis-cli` | redis-cli executable name |
| `trigger_bgsave` | `true` | Run `BGSAVE` before reading the RDB |
| `wait_timeout` | `5m` | Maximum wait for BGSAVE completion |
| `output` | `/dump.rdb` | Snapshot pathname for the emitted RDB |

## Exporter options

| Option | Default | Description |
| --- | --- | --- |
| `location` | — | `redis-file://` output path |
| `output` | — | Output path override |
| `force` | `false` | Overwrite an existing RDB file |
107 changes: 107 additions & 0 deletions redis/exporter/exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package exporter

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/PlakarKorp/kloset/connectors"
"github.com/PlakarKorp/kloset/location"
)

type Exporter struct {
proto, output string
force bool
}

func New(proto string, config map[string]string) (*Exporter, error) {
out := strings.TrimSpace(config["output"])
if out == "" {
loc := strings.TrimPrefix(config["location"], proto+"://")
if loc != "" && loc != config["location"] {
out = loc
}
}
if out == "" {
return nil, fmt.Errorf("output is required (path to restored dump.rdb)")
}
force := false
if v := config["force"]; v != "" {
switch strings.ToLower(v) {
case "1", "t", "true", "yes":
force = true
case "0", "f", "false", "no":
force = false
default:
return nil, fmt.Errorf("invalid value for force: %q", v)
}
}
return &Exporter{proto: proto, output: out, force: force}, nil
}

func (e *Exporter) Origin() string { return e.output }
func (e *Exporter) Type() string { return e.proto }
func (e *Exporter) Root() string { return "/" }
func (e *Exporter) Flags() location.Flags { return 0 }
func (e *Exporter) Ping(context.Context) error { return nil }
func (e *Exporter) Close(context.Context) error { return nil }

func (e *Exporter) Export(ctx context.Context, records <-chan *connectors.Record, results chan<- *connectors.Result) error {
defer close(results)
for record := range records {
if record.Err != nil {
results <- record.Ok()
continue
}
if record.FileInfo.Lmode.IsDir() {
results <- record.Ok()
continue
}
if filepath.Base(record.Pathname) != "dump.rdb" {
results <- record.Ok()
continue
}
if err := e.restore(ctx, record); err != nil {
results <- record.Error(err)
} else {
results <- record.Ok()
}
}
return nil
}

func (e *Exporter) restore(ctx context.Context, record *connectors.Record) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if !e.force {
if _, err := os.Stat(e.output); err == nil {
return fmt.Errorf("refusing to overwrite %s without force=true", e.output)
}
if err := os.MkdirAll(filepath.Dir(e.output), 0755); err != nil {
return err
}
f, err := os.OpenFile(e.output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, record.Reader)
return err
}
if err := os.MkdirAll(filepath.Dir(e.output), 0755); err != nil {
return err
}
f, err := os.OpenFile(e.output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, record.Reader)
return err
}
78 changes: 78 additions & 0 deletions redis/exporter/exporter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package exporter

import (
"context"
"io"
"os"
"path/filepath"
"testing"
"time"

"github.com/PlakarKorp/kloset/connectors"
"github.com/PlakarKorp/kloset/objects"
)

func TestExporterWritesDumpRDB(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "dump.rdb")
exp, err := New("redis-file", map[string]string{"output": out})
if err != nil {
t.Fatal(err)
}
records := make(chan *connectors.Record, 1)
results := make(chan *connectors.Result, 1)
records <- connectors.NewRecord("/dump.rdb", "", objects.FileInfo{Lname: "dump.rdb", Lmode: 0444, LmodTime: time.Now()}, nil, func() (io.ReadCloser, error) {
return io.NopCloser(&readString{s: "REDIS0009"}), nil
})
close(records)
if err := exp.Export(context.Background(), records, results); err != nil {
t.Fatal(err)
}
res := <-results
if res.Err != nil {
t.Fatal(res.Err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(got) != "REDIS0009" {
t.Fatalf("unexpected restored content %q", string(got))
}
}

func TestExporterRefusesOverwriteWithoutForce(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "dump.rdb")
if err := os.WriteFile(out, []byte("old"), 0644); err != nil {
t.Fatal(err)
}
exp, err := New("redis-file", map[string]string{"output": out})
if err != nil {
t.Fatal(err)
}
records := make(chan *connectors.Record, 1)
results := make(chan *connectors.Result, 1)
records <- connectors.NewRecord("/dump.rdb", "", objects.FileInfo{Lname: "dump.rdb", Lmode: 0444, LmodTime: time.Now()}, nil, func() (io.ReadCloser, error) {
return io.NopCloser(&readString{s: "new"}), nil
})
close(records)
if err := exp.Export(context.Background(), records, results); err != nil {
t.Fatal(err)
}
res := <-results
if res.Err == nil {
t.Fatal("expected overwrite refusal")
}
}

type readString struct{ s string }

func (r *readString) Read(p []byte) (int, error) {
if r.s == "" {
return 0, io.EOF
}
n := copy(p, r.s)
r.s = r.s[n:]
return n, nil
}
13 changes: 13 additions & 0 deletions redis/exporter/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"location": { "type": "string", "description": "redis-file:// path to write dump.rdb" },
"output": { "type": "string", "description": "Path to restored dump.rdb" },
"force": { "type": "boolean", "default": false }
},
"anyOf": [
{ "required": ["location"] },
{ "required": ["output"] }
]
}
39 changes: 39 additions & 0 deletions redis/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module github.com/PlakarKorp/integration-redis

go 1.25.7

require (
github.com/PlakarKorp/go-kloset-sdk v1.1.0-beta.1
github.com/PlakarKorp/kloset v1.1.0-beta.2
)

require (
github.com/PlakarKorp/integration-grpc v1.1.0-beta.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nickball/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tink-crypto/tink-go/v2 v2.6.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)
Loading