mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 05:19:39 +02:00
feat(backup): Add backup/restore to the server
This commit is contained in:
parent
c04bbb5775
commit
a3ec2f8e85
65 changed files with 2394 additions and 564 deletions
84
api/backup/backup.go
Normal file
84
api/backup/backup.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
)
|
||||
|
||||
const rwxr__r__ fs.FileMode = 0744
|
||||
|
||||
var filesToBackup = []string{"compose", "config.json", "custom_templates", "edge_jobs", "edge_stacks", "extensions", "portainer.key", "portainer.pub", "tls"}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||
}
|
||||
|
||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to backup database")
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
err := copyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
}
|
||||
|
||||
archivePath, err := archive.TarGzDir(backupDirPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to make an archive")
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
archivePath, err = encrypt(archivePath, password)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to encrypt backup with the password")
|
||||
}
|
||||
}
|
||||
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore portainer.DataStore) error {
|
||||
backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = datastore.BackupTo(backupWriter); err != nil {
|
||||
return err
|
||||
}
|
||||
return backupWriter.Close()
|
||||
}
|
||||
|
||||
func encrypt(path string, passphrase string) (string, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
outFileName := fmt.Sprintf("%s.encrypted", path)
|
||||
out, err := os.Create(outFileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||
|
||||
return outFileName, err
|
||||
}
|
68
api/backup/copy.go
Normal file
68
api/backup/copy.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func copyPath(path string, toDir string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
// skip copy if file does not exist
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
destination := filepath.Join(toDir, info.Name())
|
||||
return copyFile(path, destination)
|
||||
}
|
||||
|
||||
return copyDir(path, toDir)
|
||||
}
|
||||
|
||||
func copyDir(fromDir, toDir string) error {
|
||||
cleanedSourcePath := filepath.Clean(fromDir)
|
||||
parentDirectory := filepath.Dir(cleanedSourcePath)
|
||||
err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destination := filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
|
||||
if info.IsDir() {
|
||||
return nil // skip directory creations
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink
|
||||
return nil // don't copy symlinks
|
||||
}
|
||||
|
||||
return copyFile(path, destination)
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// copies regular a file from src to dst
|
||||
func copyFile(src, dst string) error {
|
||||
from, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer from.Close()
|
||||
|
||||
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer to.Close()
|
||||
|
||||
_, err = io.Copy(to, from)
|
||||
return err
|
||||
}
|
104
api/backup/copy_test.go
Normal file
104
api/backup/copy_test.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package backup
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func listFiles(dir string) []string {
|
||||
items := make([]string, 0)
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
items = append(items, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func contains(t *testing.T, list []string, path string) {
|
||||
assert.Contains(t, list, path)
|
||||
copyContent, _ := ioutil.ReadFile(path)
|
||||
assert.Equal(t, "content\n", string(copyContent))
|
||||
}
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
tmpdir, _ := os.MkdirTemp("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
tmpdir, _ := os.MkdirTemp("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
|
||||
|
||||
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy"))
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
destination, _ := os.MkdirTemp("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyDir("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
|
||||
createdFiles := listFiles(destination)
|
||||
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
tmpdir, _ := os.MkdirTemp("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyPath("does-not-exists", tmpdir)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Empty(t, listFiles(tmpdir))
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyFile(t *testing.T) {
|
||||
tmpdir, _ := os.MkdirTemp("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600)
|
||||
|
||||
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
|
||||
err := copyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file"))
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyDir(t *testing.T) {
|
||||
destination, _ := os.MkdirTemp("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyPath("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
|
||||
createdFiles := listFiles(destination)
|
||||
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
68
api/backup/restore.go
Normal file
68
api/backup/restore.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
)
|
||||
|
||||
var filesToRestore = append(filesToBackup, "portainer.db")
|
||||
|
||||
// Restores system state from backup archive, will trigger system shutdown, when finished.
|
||||
func RestoreArchive(archive io.Reader, password string, filestorePath string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, shutdownTrigger context.CancelFunc) error {
|
||||
var err error
|
||||
if password != "" {
|
||||
archive, err = decrypt(archive, password)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to decrypt the archive")
|
||||
}
|
||||
}
|
||||
|
||||
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
||||
defer os.RemoveAll(filepath.Dir(restorePath))
|
||||
|
||||
err = extractArchive(archive, restorePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
if err = datastore.Close(); err != nil {
|
||||
return errors.Wrap(err, "Failed to stop db")
|
||||
}
|
||||
|
||||
if err = restoreFiles(restorePath, filestorePath); err != nil {
|
||||
return errors.Wrap(err, "failed to restore the system state")
|
||||
}
|
||||
|
||||
shutdownTrigger()
|
||||
return nil
|
||||
}
|
||||
|
||||
func decrypt(r io.Reader, password string) (io.Reader, error) {
|
||||
return crypto.AesDecrypt(r, []byte(password))
|
||||
}
|
||||
|
||||
func extractArchive(r io.Reader, destinationDirPath string) error {
|
||||
return archive.ExtractTarGz(r, destinationDirPath)
|
||||
}
|
||||
|
||||
func restoreFiles(srcDir string, destinationDir string) error {
|
||||
for _, filename := range filesToRestore {
|
||||
err := copyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
1
api/backup/test_assets/copy_test/dir/.dotfile
Normal file
1
api/backup/test_assets/copy_test/dir/.dotfile
Normal file
|
@ -0,0 +1 @@
|
|||
content
|
1
api/backup/test_assets/copy_test/dir/inner
Normal file
1
api/backup/test_assets/copy_test/dir/inner
Normal file
|
@ -0,0 +1 @@
|
|||
content
|
1
api/backup/test_assets/copy_test/outer
Normal file
1
api/backup/test_assets/copy_test/outer
Normal file
|
@ -0,0 +1 @@
|
|||
content
|
Loading…
Add table
Add a link
Reference in a new issue