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

Initial Meilisearch Support #54

Merged
merged 54 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
dc98028
Initial Meilisearch Support
majst01 Aug 24, 2023
3ae5995
linter fix
majst01 Aug 24, 2023
6915783
linter fix
majst01 Aug 24, 2023
479027a
logging
majst01 Aug 24, 2023
95d3911
stupid me
majst01 Aug 24, 2023
86d4b9e
Fix dump move
majst01 Aug 24, 2023
4a8a7b6
Fix cross mountpoint move
majst01 Aug 24, 2023
a9f1079
Try native move
majst01 Aug 24, 2023
2b65b56
create latest stable dump for upgrades
majst01 Aug 24, 2023
408d73b
Upgrade
majst01 Aug 24, 2023
98abd10
fix lateststableDump
majst01 Aug 24, 2023
8e72438
fix lateststableDump
majst01 Aug 24, 2023
0241268
fix lateststableDump
majst01 Aug 24, 2023
d55a498
fix lateststableDump
majst01 Aug 24, 2023
603093e
semver comparison
majst01 Aug 24, 2023
bc3aac8
Ignore existing db
majst01 Aug 24, 2023
f90e1d0
Fix upgrade
majst01 Aug 24, 2023
562523c
Fix upgrade
majst01 Aug 24, 2023
4576437
Fix upgrade
majst01 Aug 24, 2023
a335f66
Fix upgrade
majst01 Aug 24, 2023
925f6a5
Kill database after upgrade
majst01 Aug 24, 2023
17e9489
Kill database after upgrade
majst01 Aug 24, 2023
2171467
Errgroup
majst01 Aug 25, 2023
6e0e9aa
fixes
majst01 Aug 25, 2023
99daca5
refinements
majst01 Aug 25, 2023
9fcff68
simplification and implement recover
majst01 Aug 25, 2023
884be8c
dump creation fix
majst01 Aug 25, 2023
8fadb33
Merge branch 'master' of https://github.com/metal-stack/backup-restor…
majst01 Aug 25, 2023
28193e0
Fix backup copy
majst01 Aug 25, 2023
da7cc39
Fix backup copy
majst01 Aug 25, 2023
6fc7fc9
Fix backup copy
majst01 Aug 25, 2023
e6598ac
Merge master
majst01 Sep 4, 2023
b7b48ab
Larger linting timeout
majst01 Sep 4, 2023
ab9adc2
Initial integration test
majst01 Sep 6, 2023
fd2a4a8
Linter fix
majst01 Sep 7, 2023
f4283bd
Make Meilisearch upgrade pass
majst01 Sep 7, 2023
74b64d7
Update modules
majst01 Sep 7, 2023
db5fec9
Raise integration test timeout
majst01 Sep 7, 2023
3c444a9
Do not change unrelated
majst01 Sep 8, 2023
b1d4da7
Obsolet manifest
majst01 Sep 8, 2023
09fea55
First round of review comments
majst01 Sep 12, 2023
943ba85
Make CI pass again
majst01 Sep 13, 2023
9a934f8
Better explanation of the bad situation
majst01 Sep 13, 2023
11e6aa8
Move examples to separate package and enable generating them without …
Gerrit91 Sep 18, 2023
7f0dc84
First version of upgrade by saving previous binary, also bug fixes.
Gerrit91 Sep 18, 2023
c076b7c
Stabilize integration tests.
Gerrit91 Sep 18, 2023
cea623a
Merge remote-tracking branch 'origin/master' into meilisearch-support
Gerrit91 Sep 18, 2023
07b4efd
Typo.
Gerrit91 Sep 18, 2023
a384aeb
Improvements on example generation.
Gerrit91 Sep 19, 2023
9390f6f
Another review with some improvements.
Sep 19, 2023
aa2cc72
Better wait for restore.
Sep 19, 2023
d39e57b
Reformat table.
Sep 19, 2023
423ce08
Remove unnecessary linter exclusion.
Sep 19, 2023
5988ef2
Add validation.
Gerrit91 Sep 20, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
- name: Lint
uses: golangci/golangci-lint-action@v3
with:
args: --build-tags integration -p bugs -p unused --timeout=3m
args: --build-tags integration -p bugs -p unused --timeout=10m

- name: Test
run: |
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ test: build
.PHONY: test-integration
test-integration: kind-cluster-create
kind --name backup-restore-sidecar load docker-image ghcr.io/metal-stack/backup-restore-sidecar:latest
KUBECONFIG=$(KUBECONFIG) go test $(GO_RUN_ARG) -tags=integration -count 1 -v -p 1 -timeout 10m ./...
KUBECONFIG=$(KUBECONFIG) go test $(GO_RUN_ARG) -tags=integration -count 1 -v -p 1 -timeout 20m ./...

.PHONY: proto
proto:
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ Probably, it does not make sense to use this project with large databases. Howev

## Supported Databases

| Database | Image | Status | Upgrade Support |
| --------- | ------------ | :----: | :-------------: |
| postgres | >= 12-alpine | beta | ✅ |
| rethinkdb | >= 2.4.0 | beta | ❌ |
| ETCD | >= 3.5 | alpha | ❌ |
| Database | Image | Status | Upgrade Support |
|-------------|--------------|:------:|:---------------:|
| postgres | >= 12-alpine | beta | ✅ |
| rethinkdb | >= 2.4.0 | beta | ❌ |
| ETCD | >= 3.5 | alpha | ❌ |
| meilisearch | >= 1.2.0 | alpha | ✅ |
Gerrit91 marked this conversation as resolved.
Show resolved Hide resolved

## Database Upgrades

Expand Down
329 changes: 329 additions & 0 deletions cmd/internal/database/meilisearch/meilisearch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
package meilisearch

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"
"syscall"
"time"

"github.com/Masterminds/semver/v3"
"github.com/avast/retry-go/v4"
"github.com/meilisearch/meilisearch-go"
"golang.org/x/sync/errgroup"

"github.com/metal-stack/backup-restore-sidecar/cmd/internal/utils"
"github.com/metal-stack/backup-restore-sidecar/pkg/constants"
"go.uber.org/zap"
)

const (
meilisearchCmd = "meilisearch"
meilisearchVersionFile = "VERSION"
meilisearchDBDir = "data.ms"
meilisearchDumpDir = "dumps"
dumpExtension = ".dump"
latestStableDump = "latest.dump"
)

// Meilisearch implements the database interface
type Meilisearch struct {
dbdir string
dumpdir string
apikey string
latestStableDumpDst string
log *zap.SugaredLogger
executor *utils.CmdExecutor
client *meilisearch.Client
}

// New instantiates a new meilisearch database
func New(log *zap.SugaredLogger, datadir string, url string, apikey string) (*Meilisearch, error) {
client := meilisearch.NewClient(meilisearch.ClientConfig{
Host: url,
APIKey: apikey,
})
dbdir := path.Join(datadir, meilisearchDBDir)
if _, err := os.Stat(dbdir); os.IsNotExist(err) {
err := os.MkdirAll(dbdir, 0700)
if err != nil {
return nil, fmt.Errorf("dbdir %q does not exist but creation failed %w", dbdir, err)
}
}

dumpdir := path.Join(datadir, meilisearchDumpDir)
if _, err := os.Stat(dbdir); os.IsNotExist(err) {
err := os.MkdirAll(dumpdir, 0700)
if err != nil {
return nil, fmt.Errorf("dumpdir %q does not exist but creation failed %w", dumpdir, err)
}
}
Gerrit91 marked this conversation as resolved.
Show resolved Hide resolved

latestStableDumpDst := path.Join(dumpdir, latestStableDump)
return &Meilisearch{
log: log,
dbdir: dbdir,
dumpdir: dumpdir,
apikey: apikey,
latestStableDumpDst: latestStableDumpDst,
executor: utils.NewExecutor(log),
client: client,
}, nil
}

// Backup implements database.Database.
func (db *Meilisearch) Backup(ctx context.Context) error {
if err := os.RemoveAll(constants.BackupDir); err != nil {
return fmt.Errorf("could not clean backup directory: %w", err)
}

if err := os.MkdirAll(constants.BackupDir, 0777); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this too broad? Would 755 or something similar be enough?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably yes, but there are many other places where same permissions are used. Maybe we should create a separate PR and address this.

return fmt.Errorf("could not create backup directory: %w", err)
}

dumpResponse, err := db.client.CreateDump()
if err != nil {
return fmt.Errorf("could not create a dump: %w", err)
}

db.log.Infow("dump creation triggered", "response", dumpResponse)
majst01 marked this conversation as resolved.
Show resolved Hide resolved

err = retry.Do(func() error {
dumpTask, err := db.client.GetTask(dumpResponse.TaskUID)
if err != nil {
return err
}
if dumpTask.Status != meilisearch.TaskStatusSucceeded {
majst01 marked this conversation as resolved.
Show resolved Hide resolved
return fmt.Errorf("dump still processing")
}

db.log.Infow("dump finished", "duration", dumpTask.Duration, "details", dumpTask.Details)
return nil
}, retry.Attempts(100), retry.Context(ctx))
if err != nil {
return err
}
err = db.moveDumpToBackupDir(ctx)
if err != nil {
return err
}

db.log.Debugw("successfully took backup of meilisearch")

return nil
}

// Check implements database.Database.
func (db *Meilisearch) Check(_ context.Context) (bool, error) {
empty, err := utils.IsEmpty(db.dbdir)
if err != nil {
return false, err
}
if empty {
db.log.Info("data directory is empty")
return true, err
}

return false, nil
}

// Probe implements database.Database.
func (db *Meilisearch) Probe(_ context.Context) error {
_, err := db.client.Version()
if err != nil {
return fmt.Errorf("connection error:%w", err)
}
return nil
}

// Recover implements database.Database.
func (db *Meilisearch) Recover(ctx context.Context) error {
dump := path.Join(constants.RestoreDir, latestStableDump)
if _, err := os.Stat(dump); os.IsNotExist(err) {
return fmt.Errorf("restore file not present: %s", dump)
}
start := time.Now()

if err := utils.RemoveContents(db.dbdir); err != nil {
return fmt.Errorf("could not clean database data directory: %w", err)
}

err := db.importDump(ctx, dump)
if err != nil {
return fmt.Errorf("unable to recover %w", err)
}

db.log.Infow("recovery done", "duration", time.Since(start))
return nil
}

// Upgrade implements database.Database.
func (db *Meilisearch) Upgrade(ctx context.Context) error {
start := time.Now()

versionFile := path.Join(db.dbdir, meilisearchVersionFile)
if _, err := os.Stat(versionFile); errors.Is(err, fs.ErrNotExist) {
db.log.Infof("%q is not present, no upgrade required", versionFile)
return nil
}

dbVersion, err := db.getDatabaseVersion(versionFile)
if err != nil {
return err
}
meilisearchVersion, err := db.getBinaryVersion(ctx)
if err != nil {
return err
}
if (dbVersion.Major() == meilisearchVersion.Major()) && (dbVersion.Minor() == meilisearchVersion.Minor()) {
db.log.Infow("no version difference, no upgrade required", "database-version", dbVersion, "binary-version", meilisearchVersion)
return nil
}
if dbVersion.GreaterThan(meilisearchVersion) {
db.log.Errorw("database is newer than meilisearch binary, aborting", "database-version", dbVersion, "binary-version", meilisearchVersion)
return fmt.Errorf("database is newer than meilisearch binary")
}
if _, err := os.Stat(db.latestStableDumpDst); errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("%q is not present, no upgrade possible, maybe no backup was running before", db.latestStableDumpDst)
majst01 marked this conversation as resolved.
Show resolved Hide resolved
}

db.log.Infow("start upgrade", "from", dbVersion, "to", meilisearchVersion)

err = os.Rename(db.dbdir, db.dbdir+".old")
if err != nil {
return fmt.Errorf("unable to rename dbdir: %w", err)
}

err = db.importDump(ctx, db.latestStableDumpDst)
if err != nil {
return err
}
db.log.Infow("upgrade done and new data in place", "took", time.Since(start))
return nil
}

func (db *Meilisearch) importDump(ctx context.Context, dump string) error {
var (
err error
cmd *exec.Cmd
g, _ = errgroup.WithContext(ctx)
)

g.Go(func() error {
args := []string{"--import-dump", dump, "--master-key", db.apikey}
db.log.Infow("execute meilisearch", "args", args)

cmd = exec.CommandContext(ctx, meilisearchCmd, args...) // nolint:gosec
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("unable import dump %w", err)
majst01 marked this conversation as resolved.
Show resolved Hide resolved
}
db.log.Info("import of dump finished")
return nil
})

// TODO big databases might take longer, not sure if 100 attempts are enough
// must check how long it take max with backoff ?
err = retry.Do(func() error {
v, err := db.client.Version()
majst01 marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}
healthy := db.client.IsHealthy()
if !healthy {
return fmt.Errorf("meilisearch does not report healthiness")
}
db.log.Infow("meilisearch started after importing the dump, killing it", "version", v)
return cmd.Process.Signal(syscall.SIGTERM)
}, retry.Attempts(100), retry.Context(ctx))
if err != nil {
return err
}
err = g.Wait()
if err != nil {
// sending a TERM signal will always result in a error response.
db.log.Infow("importing dump terminated but reported an error which can be ignored", "error", err)
}
return nil
}

// moveDumpToBackupDir move all dumps to the backupdir
// also create a stable last stable dump for later upgrades
func (db *Meilisearch) moveDumpToBackupDir(ctx context.Context) error {
dumps, err := filepath.Glob(db.dumpdir + "/*.dump")
if err != nil {
return fmt.Errorf("unable to find dumps %w", err)
}
majst01 marked this conversation as resolved.
Show resolved Hide resolved
src := ""
// sort them an take only the latest dump
slices.Sort(dumps)
for _, dump := range dumps {
majst01 marked this conversation as resolved.
Show resolved Hide resolved
if strings.Contains(dump, latestStableDump) {
continue
}
src = dump
}

db.log.Infow("create latest dump rename", "from", src, "to", db.latestStableDumpDst)
err = os.Rename(src, db.latestStableDumpDst)
if err != nil {
return fmt.Errorf("unable create latest stable dump: %w", err)
}

backupDst := path.Join(constants.BackupDir, latestStableDump)
db.log.Infow("copy dump", "from", db.latestStableDumpDst, "to", backupDst)
copy := exec.CommandContext(ctx, "cp", "-v", db.latestStableDumpDst, backupDst) // nolint:gosec
copy.Stdout = os.Stdout
copy.Stderr = os.Stderr
err = copy.Run()
if err != nil {
return fmt.Errorf("unable move dump: %w", err)
}
return nil
}

func (db *Meilisearch) getDatabaseVersion(versionFile string) (*semver.Version, error) {
// cat VERSION
// 1.2.0
versionBytes, err := os.ReadFile(versionFile)
if err != nil {
return nil, fmt.Errorf("unable to read %q: %w", versionFile, err)
}

v, err := semver.NewVersion(strings.TrimSpace(string(versionBytes)))
if err != nil {
return nil, fmt.Errorf("unable to parse postgres binary version in %q: %w", string(versionBytes), err)
majst01 marked this conversation as resolved.
Show resolved Hide resolved
}
return v, nil
}

func (db *Meilisearch) getBinaryVersion(ctx context.Context) (*semver.Version, error) {
// meilisearch --version
// 1.2.0
cmd := exec.CommandContext(ctx, meilisearchCmd, "--version")
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("unable to detect meilisearch binary version: %w", err)
}

_, binaryVersionString, found := strings.Cut(string(out), "meilisearch ")
if !found {
return nil, fmt.Errorf("unable to detect meilisearch binary version in %q", binaryVersionString)
}

v, err := semver.NewVersion(strings.TrimSpace(binaryVersionString))
if err != nil {
return nil, fmt.Errorf("unable to parse meilisearch binary version in %q: %w", binaryVersionString, err)
}

return v, nil
}
Loading