Compare commits

..

2 Commits

Author SHA1 Message Date
andres-portainer
5eec7de7e6 Add missing unit test. 2023-11-15 16:57:43 -03:00
andres-portainer
fc3833a445 fix(stacks): validate the build context EE-6211 2023-11-15 16:57:37 -03:00
20 changed files with 616 additions and 514 deletions

View File

@@ -49,7 +49,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),

View File

@@ -9,7 +9,7 @@ import (
// Confirm starts a rollback db cli application
func Confirm(message string) (bool, error) {
fmt.Printf("%s [y/N] ", message)
fmt.Printf("%s [y/N]", message)
reader := bufio.NewReader(os.Stdin)

View File

@@ -1,77 +1,189 @@
package datastore
import (
"fmt"
"os"
"path"
"time"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
func (store *Store) Backup() (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
err := store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
if err != nil {
log.Warn().Err(err).Msg("failed to create backup file")
return "", err
}
return backupFilename, nil
var backupDefaults = struct {
backupDir string
commonDir string
}{
"backups",
"common",
}
func (store *Store) Restore() error {
backupFilename := store.backupFilename()
return store.RestoreFromFile(backupFilename)
}
//
// Backup Helpers
//
func (store *Store) RestoreFromFile(backupFilename string) error {
if exists, _ := store.fileService.FileExists(backupFilename); !exists {
log.Error().Str("backupFilename", backupFilename).Msg("backup file does not exist")
return os.ErrNotExist
}
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
log.Error().Err(err).Msg("error while restoring backup.")
return err
}
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("database restored")
// determine the db version
store.Open()
version, err := store.VersionService.Version()
edition := "CE"
if version.Edition == 2 {
edition = "EE"
}
if err == nil {
log.Info().Str("version", version.SchemaVersion).Msgf("Restored database version: Portainer %s %s", edition, version.SchemaVersion)
}
return nil
}
func (store *Store) createBackupPath() error {
backupDir := path.Join(store.connection.GetStorePath(), "backups")
if exists, _ := store.fileService.FileExists(backupDir); !exists {
if err := os.MkdirAll(backupDir, 0700); err != nil {
log.Error().Err(err).Msg("error while creating backup folder")
return err
// createBackupFolders create initial folders for backups
func (store *Store) createBackupFolders() {
// create common dir
commonDir := store.commonBackupDir()
if exists, _ := store.fileService.FileExists(commonDir); !exists {
if err := os.MkdirAll(commonDir, 0700); err != nil {
log.Error().Err(err).Msg("error while creating common backup folder")
}
}
return nil
}
func (store *Store) backupFilename() string {
return path.Join(store.connection.GetStorePath(), "backups", store.connection.GetDatabaseFileName()+".bak")
}
func (store *Store) databasePath() string {
return store.connection.GetDatabaseFilePath()
}
func (store *Store) commonBackupDir() string {
return path.Join(store.connection.GetStorePath(), backupDefaults.backupDir, backupDefaults.commonDir)
}
func (store *Store) copyDBFile(from string, to string) error {
log.Info().Str("from", from).Str("to", to).Msg("copying DB file")
err := store.fileService.Copy(from, to, true)
if err != nil {
log.Error().Err(err).Msg("failed")
}
return err
}
// BackupOptions provide a helper to inject backup options
type BackupOptions struct {
Version string
BackupDir string
BackupFileName string
BackupPath string
}
// getBackupRestoreOptions returns options to store db at common backup dir location; used by:
// - db backup prior to version upgrade
// - db rollback
func getBackupRestoreOptions(backupDir string) *BackupOptions {
return &BackupOptions{
BackupDir: backupDir, //connection.commonBackupDir(),
BackupFileName: beforePortainerVersionUpgradeBackup,
}
}
// Backup current database with default options
func (store *Store) Backup(version *models.Version) (string, error) {
if version == nil {
return store.backupWithOptions(nil)
}
return store.backupWithOptions(&BackupOptions{
Version: version.SchemaVersion,
})
}
func (store *Store) setupOptions(options *BackupOptions) *BackupOptions {
if options == nil {
options = &BackupOptions{}
}
if options.Version == "" {
v, err := store.VersionService.Version()
if err != nil {
options.Version = ""
}
options.Version = v.SchemaVersion
}
if options.BackupDir == "" {
options.BackupDir = store.commonBackupDir()
}
if options.BackupFileName == "" {
options.BackupFileName = fmt.Sprintf("%s.%s.%s", store.connection.GetDatabaseFileName(), options.Version, time.Now().Format("20060102150405"))
}
if options.BackupPath == "" {
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
}
return options
}
// BackupWithOptions backup current database with options
func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
log.Info().Msg("creating DB backup")
store.createBackupFolders()
options = store.setupOptions(options)
dbPath := store.databasePath()
if err := store.Close(); err != nil {
return options.BackupPath, fmt.Errorf(
"error closing datastore before creating backup: %w",
err,
)
}
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
return options.BackupPath, err
}
if _, err := store.Open(); err != nil {
return options.BackupPath, fmt.Errorf(
"error opening datastore after creating backup: %w",
err,
)
}
return options.BackupPath, nil
}
// RestoreWithOptions previously saved backup for the current Edition with options
// Restore strategies:
// - default: restore latest from current edition
// - restore a specific
func (store *Store) restoreWithOptions(options *BackupOptions) error {
options = store.setupOptions(options)
// Check if backup file exist before restoring
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to restore does not exist %s")
return err
}
err = store.Close()
if err != nil {
log.Error().Err(err).Msg("error while closing store before restore")
return err
}
log.Info().Msg("restoring DB backup")
err = store.copyDBFile(options.BackupPath, store.databasePath())
if err != nil {
return err
}
_, err = store.Open()
return err
}
// RemoveWithOptions removes backup database based on supplied options
func (store *Store) removeWithOptions(options *BackupOptions) error {
log.Info().Msg("removing DB backup")
options = store.setupOptions(options)
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to remove does not exist")
return err
}
log.Info().Str("path", options.BackupPath).Msg("removing DB file")
err = os.Remove(options.BackupPath)
if err != nil {
log.Error().Err(err).Msg("failed")
return err
}
return nil
}

View File

@@ -2,79 +2,106 @@ package datastore
import (
"fmt"
"os"
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
func TestCreateBackupFolders(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
connection := store.GetConnection()
backupPath := path.Join(connection.GetStorePath(), backupDefaults.backupDir)
if isFileExist(backupPath) {
t.Error("Expect backups folder to not exist")
}
store.createBackupFolders()
if !isFileExist(backupPath) {
t.Error("Expect backups folder to exist")
}
}
func TestStoreCreation(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
if store == nil {
t.Fatal("Expect to create a store")
t.Error("Expect to create a store")
}
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if portainer.SoftwareEdition(v.Edition) != portainer.PortainerCE {
if store.CheckCurrentEdition() != nil {
t.Error("Expect to get CE Edition")
}
if v.SchemaVersion != portainer.APIVersion {
t.Error("Expect to get APIVersion")
}
}
func TestBackup(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
backupFileName := store.backupFilename()
t.Run(fmt.Sprintf("Backup should create %s", backupFileName), func(t *testing.T) {
connection := store.GetConnection()
t.Run("Backup should create default db backup", func(t *testing.T) {
v := models.Version{
Edition: int(portainer.PortainerCE),
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.Backup()
store.backupWithOptions(nil)
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", fmt.Sprintf("portainer.edb.%s.*", portainer.APIVersion))
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
t.Run("BackupWithOption should create a name specific backup at common path", func(t *testing.T) {
store.backupWithOptions(&BackupOptions{
BackupFileName: beforePortainerVersionUpgradeBackup,
BackupDir: store.commonBackupDir(),
})
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", beforePortainerVersionUpgradeBackup)
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
}
func TestRestore(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
func TestRemoveWithOptions(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
t.Run(fmt.Sprintf("Basic Restore"), func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
t.Run("successfully removes file if existent", func(t *testing.T) {
store.createBackupFolders()
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
store.Backup()
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
filePath := path.Join(options.BackupDir, options.BackupFileName)
f, err := os.Create(filePath)
if err != nil {
t.Fatalf("file should be created; err=%s", err)
}
f.Close()
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
err = store.removeWithOptions(options)
if err != nil {
t.Errorf("RemoveWithOptions should successfully remove file; err=%v", err)
}
if isFileExist(f.Name()) {
t.Errorf("RemoveWithOptions should successfully remove file; file=%s", f.Name())
}
})
t.Run(fmt.Sprintf("Basic Restore After Multiple Backups"), func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup()
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
t.Run("fails to removes file if non-existent", func(t *testing.T) {
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
err := store.removeWithOptions(options)
if err == nil {
t.Error("RemoveWithOptions should fail for non-existent file")
}
})
}

View File

@@ -31,14 +31,8 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup()
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}
err = store.encryptDB()
if err != nil {
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, err
}
}

View File

@@ -1,68 +0,0 @@
package datastore
import (
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
func updateVersion(store *Store, v string) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.SchemaVersion = v
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
func updateEdition(store *Store, edition portainer.SoftwareEdition) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.Edition = int(edition)
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}
func testEdition(store *Store, editionWant portainer.SoftwareEdition, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if portainer.SoftwareEdition(v.Edition) != editionWant {
t.Errorf("Expect store edition to be %s but was %s", editionWant.GetEditionLabel(), portainer.SoftwareEdition(v.Edition).GetEditionLabel())
}
}

View File

@@ -2,7 +2,6 @@ package datastore
import (
"fmt"
"os"
"runtime/debug"
portainer "github.com/portainer/portainer/api"
@@ -16,6 +15,8 @@ import (
"github.com/rs/zerolog/log"
)
const beforePortainerVersionUpgradeBackup = "portainer.db.bak"
func (store *Store) MigrateData() error {
updating, err := store.VersionService.IsUpdating()
if err != nil {
@@ -40,7 +41,7 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
_, err = store.Backup()
backupPath, err := store.Backup(version)
if err != nil {
return errors.Wrap(err, "while backing up database")
}
@@ -50,9 +51,9 @@ func (store *Store) MigrateData() error {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
restoreErr := store.Restore()
if restoreErr != nil {
return errors.Wrap(restoreErr, "failed to restore database")
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if restorErr != nil {
return errors.Wrap(restorErr, "failed to restore database")
}
log.Info().Msg("database restored to previous version")
@@ -116,11 +117,6 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
return err
}
// Special test code to simulate a failure (used by migrate_data_test.go). Do not remove...
if os.Getenv("PORTAINER_TEST_MIGRATE_FAIL") == "FAIL" {
panic("test migration failure")
}
err = store.VersionService.StoreIsUpdating(false)
if err != nil {
return errors.Wrap(err, "failed to update the store")
@@ -139,7 +135,9 @@ func (store *Store) connectionRollback(force bool) error {
}
}
err := store.Restore()
options := getBackupRestoreOptions(store.commonBackupDir())
err := store.restoreWithOptions(options)
if err != nil {
return err
}

View File

@@ -7,20 +7,29 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/rs/zerolog/log"
"github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Expect store version to be %s but was %s with error: %s", versionWant, v.SchemaVersion, err)
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}
func TestMigrateData(t *testing.T) {
tests := []struct {
snapshotTests := []struct {
testName string
srcPath string
wantPath string
@@ -33,7 +42,7 @@ func TestMigrateData(t *testing.T) {
overrideInstanceId: true,
},
}
for _, test := range tests {
for _, test := range snapshotTests {
t.Run(test.testName, func(t *testing.T) {
err := migrateDBTestHelper(t, test.srcPath, test.wantPath, test.overrideInstanceId)
if err != nil {
@@ -46,167 +55,147 @@ func TestMigrateData(t *testing.T) {
})
}
t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
newStore, store := MustNewTestStore(t, true, false)
if !newStore {
t.Error("Expect a new DB")
}
// t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
// newStore, store, teardown := MustNewTestStore(t, true, false)
// defer teardown()
testVersion(store, portainer.APIVersion, t)
store.Close()
// if !newStore {
// t.Error("Expect a new DB")
// }
newStore, _ = store.Open()
if newStore {
t.Error("Expect store to NOT be new DB")
}
})
// testVersion(store, portainer.APIVersion, t)
// store.Close()
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
store.MigrateData()
// newStore, _ = store.Open()
// if newStore {
// t.Error("Expect store to NOT be new DB")
// }
// })
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("Expect backup file to be created %s", backupfilename)
}
})
// tests := []struct {
// version string
// expectedVersion string
// }{
// {version: "1.24.1", expectedVersion: portainer.APIVersion},
// {version: "2.0.0", expectedVersion: portainer.APIVersion},
// }
// for _, tc := range tests {
// _, store, teardown := MustNewTestStore(t, true, true)
// defer teardown()
t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) {
os.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
// // Setup data
// v := models.Version{SchemaVersion: tc.version}
// store.VersionService.UpdateVersion(&v)
version := "2.15"
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
store.MigrateData()
// // Required roles by migrations 22.2
// store.RoleService.Create(&portainer.Role{ID: 1})
// store.RoleService.Create(&portainer.Role{ID: 2})
// store.RoleService.Create(&portainer.Role{ID: 3})
// store.RoleService.Create(&portainer.Role{ID: 4})
store.Open()
testVersion(store, version, t)
})
// t.Run(fmt.Sprintf("MigrateData for version %s", tc.version), func(t *testing.T) {
// store.MigrateData()
// testVersion(store, tc.expectedVersion, t)
// })
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.StoreIsUpdating(true)
store.MigrateData()
// t.Run(fmt.Sprintf("Restoring DB after migrateData for version %s", tc.version), func(t *testing.T) {
// store.Rollback(true)
// store.Open()
// testVersion(store, tc.version, t)
// })
// }
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// v := models.Version{SchemaVersion: "1.24.1"}
// store.VersionService.UpdateVersion(&v)
// Set migrator the count to match our migrations array (simulate no changes).
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// store.MigrateData()
migratorParams := store.newMigratorParameters(v)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()
// testVersion(store, v.SchemaVersion, t)
// })
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
v.MigratorCount = len(latestMigrations.MigrationFuncs)
store.VersionService.UpdateVersion(v)
}
// t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
store.MigrateData()
// v := models.Version{SchemaVersion: "0.0.0"}
// store.VersionService.UpdateVersion(&v)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// store.MigrateData()
t.Run("MigrateData should create backup on startup if portainer version matches db and migrationFuncs counts differ", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// Set migrator count very large to simulate changes
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// if !isFileExist(options.BackupPath) {
// t.Errorf("Backup file should exist; file=%s", options.BackupPath)
// }
// })
v.MigratorCount = 1000
store.VersionService.UpdateVersion(v)
store.MigrateData()
// t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("DB backup should exist and there should be no error")
}
})
// store.VersionService.StoreIsUpdating(true)
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
// t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
}
func Test_getBackupRestoreOptions(t *testing.T) {
_, store := MustNewTestStore(t, false, true)
options := getBackupRestoreOptions(store.commonBackupDir())
wantDir := store.commonBackupDir()
if !strings.HasSuffix(options.BackupDir, wantDir) {
log.Fatal().Str("got", options.BackupDir).Str("want", wantDir).Msg("incorrect backup dir")
}
wantFilename := "portainer.db.bak"
if options.BackupFileName != wantFilename {
log.Fatal().Str("got", options.BackupFileName).Str("want", wantFilename).Msg("incorrect backup file")
}
}
func TestRollback(t *testing.T) {
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.11"
v := models.Version{
SchemaVersion: version,
}
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
if err != nil {
log.Fatal().Err(err).Msg("")
}
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
if err != nil {
log.Fatal().Err(err).Msg("")
}
err = store.Rollback(true)
if err != nil {
t.Logf("Rollback failed: %s", err)
t.Fail()
return
}
store.Open()
testVersion(store, version, t)
})
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.15"
v := models.Version{
SchemaVersion: version,
Edition: int(portainer.PortainerCE),
}
version := models.Version{SchemaVersion: "2.4.0"}
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
err := store.VersionService.UpdateVersion(&version)
if err != nil {
t.Errorf("Failed updating version: %v", err)
}
_, err = store.backupWithOptions(getBackupRestoreOptions(store.commonBackupDir()))
if err != nil {
log.Fatal().Err(err).Msg("")
}
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
// Change the current version
version2 := models.Version{SchemaVersion: "2.6.0"}
err = store.VersionService.UpdateVersion(&version2)
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -218,11 +207,26 @@ func TestRollback(t *testing.T) {
return
}
store.Open()
testVersion(store, version, t)
_, err = store.Open()
if err != nil {
t.Logf("Open failed: %s", err)
t.Fail()
return
}
testVersion(store, version.SchemaVersion, t)
})
}
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
// migrateDBTestHelper loads a json representation of a bolt database from srcPath,
// parses it into a database, runs a migration on that database, and then
// compares it with an expected output database.

View File

@@ -66,10 +66,7 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
func (tx *StoreTx) Stack() dataservices.StackService {
return tx.store.StackService.Tx(tx.tx)
}
func (tx *StoreTx) Stack() dataservices.StackService { return nil }
func (tx *StoreTx) Tag() dataservices.TagService {
return tx.store.TagService.Tx(tx.tx)

View File

@@ -944,6 +944,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.19.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.19.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -84,7 +84,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.19.4
// @version 2.19.2
// @description.markdown api-description.md
// @termsOfService

View File

@@ -1561,7 +1561,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.19.4"
APIVersion = "2.19.2"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -2065,20 +2065,6 @@ const (
OperationIntegrationStoridgeAdmin Authorization = "IntegrationStoridgeAdmin"
)
// GetEditionLabel returns the portainer edition label
func (e SoftwareEdition) GetEditionLabel() string {
switch e {
case PortainerCE:
return "CE"
case PortainerBE:
return "BE"
case PortainerEE:
return "EE"
}
return "CE"
}
const (
AzurePathContainerGroups = "/subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups"
AzurePathContainerGroup = "/subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*"

View File

@@ -27,61 +27,50 @@ func (e *StackAuthorMissingErr) Error() string {
return fmt.Sprintf("stack's %v author %s is missing", e.stackID, e.authorName)
}
var errDoNothing = errors.New("do nothing")
// RedeployWhenChanged pull and redeploy the stack when git repo changed
// Stack will always be redeployed if force deployment is set to true
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error {
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
var stack *portainer.Stack
var endpoint *portainer.Endpoint
var user *portainer.User
var registries []portainer.Registry
err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
stack, err = tx.Stack().Read(stackID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
if stack.GitConfig == nil {
return errDoNothing // do nothing if it isn't a git-based stack
}
endpoint, err = tx.Endpoint().Endpoint(stack.EndpointID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
}
user, err = validateAuthor(tx, stack)
if err != nil {
return err
}
registries, err = getUserRegistries(tx, user, endpoint.ID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
}
return err
})
if errors.Is(err, errDoNothing) {
return nil
stack, err := datastore.Stack().Read(stackID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return err
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
if stack.GitConfig == nil {
return nil // do nothing if it isn't a git-based stack
}
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
}
author := stack.UpdatedBy
if author == "" {
author = stack.CreatedBy
}
user, err := datastore.User().UserByUsername(author)
if err != nil {
log.Warn().
Int("stack_id", int(stackID)).
Str("author", author).
Str("stack", stack.Name).
Int("endpoint_id", int(stack.EndpointID)).
Msg("cannot auto update a stack, stack author user is missing")
return &StackAuthorMissingErr{int(stack.ID), author}
}
if !isEnvironmentOnline(endpoint) {
@@ -106,58 +95,58 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return nil
}
if err := deployStack(deployer, stack, endpoint, user, registries); err != nil {
registries, err := getUserRegistries(datastore, user, endpoint.ID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
} else if err != nil {
return err
}
err = datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
latestStack, err := tx.Stack().Read(stack.ID)
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
}
if err != nil {
return err
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
}
if gitCommitChangedOrForceUpdate {
if latestStack.GitConfig != nil && stack.GitConfig != nil {
latestStack.GitConfig.ConfigHash = stack.GitConfig.ConfigHash
}
latestStack.UpdateDate = time.Now().Unix()
case portainer.DockerSwarmStack:
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
}
if err != nil {
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
}
case portainer.KubernetesStack:
log.Debug().
Int("stack_id", int(stackID)).
Msg("deploying a kube app")
latestStack.Status = portainer.StackStatusActive
err := deployer.DeployKubernetesStack(stack, endpoint, user)
if err != nil {
return errors.WithMessagef(err, "failed to deploy a kubernetes app stack %v", stackID)
}
default:
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
}
return tx.Stack().Update(stack.ID, latestStack)
})
if err != nil {
stack.Status = portainer.StackStatusActive
if err := datastore.Stack().Update(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
}
return nil
}
func validateAuthor(tx dataservices.DataStoreTx, stack *portainer.Stack) (*portainer.User, error) {
author := stack.UpdatedBy
if author == "" {
author = stack.CreatedBy
}
user, err := tx.User().UserByUsername(author)
if err != nil {
log.Warn().
Int("stack_id", int(stack.ID)).
Str("author", author).
Str("stack", stack.Name).
Int("endpoint_id", int(stack.EndpointID)).
Msg("cannot auto update a stack, stack author user is missing")
return nil, &StackAuthorMissingErr{int(stack.ID), author}
}
return user, nil
}
func getUserRegistries(tx dataservices.DataStoreTx, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
registries, err := tx.Registry().ReadAll()
func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
registries, err := datastore.Registry().ReadAll()
if err != nil {
return nil, errors.WithMessage(err, "unable to retrieve registries from the database")
}
@@ -166,7 +155,7 @@ func getUserRegistries(tx dataservices.DataStoreTx, user *portainer.User, endpoi
return registries, nil
}
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", user.Username)
}
@@ -182,11 +171,6 @@ func getUserRegistries(tx dataservices.DataStoreTx, user *portainer.User, endpoi
}
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
if endpoint.Type != portainer.AgentOnDockerEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment {
return true
}
var err error
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
@@ -199,48 +183,3 @@ func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
return err == nil
}
func deployStack(
deployer StackDeployer,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
user *portainer.User,
registries []portainer.Registry,
) error {
var err error
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
}
if err != nil {
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID)
}
case portainer.DockerSwarmStack:
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
}
if err != nil {
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID)
}
case portainer.KubernetesStack:
log.Debug().
Int("stack_id", int(stack.ID)).
Msg("deploying a kube app")
err := deployer.DeployKubernetesStack(stack, endpoint, user)
if err != nil {
return errors.WithMessagef(err, "failed to deploy a kubernetes app stack %v", stack.ID)
}
default:
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
}
return nil
}

View File

@@ -2,13 +2,14 @@ package deployments
import (
"fmt"
"log"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type ComposeStackDeploymentConfig struct {
@@ -55,15 +56,22 @@ func (config *ComposeStackDeploymentConfig) GetUsername() string {
if config.user != nil {
return config.user.Username
}
return ""
}
func (config *ComposeStackDeploymentConfig) Deploy() error {
if config.FileService == nil || config.StackDeployer == nil {
log.Println("[deployment, compose] file service or stack deployer is not initialised")
log.Error().Msg("file service or stack deployer is not initialised")
return errors.New("file service or stack deployer cannot be nil")
}
err := stackutils.ValidateStackFiles(config.stack, stackutils.IsValidBuildContext, config.FileService)
if err != nil {
return err
}
isAdminOrEndpointAdmin, err := stackutils.UserIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
if err != nil {
return errors.Wrap(err, "failed to validate user admin privileges")
@@ -79,11 +87,14 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
!securitySettings.AllowContainerCapabilitiesForRegularUsers) &&
!isAdminOrEndpointAdmin {
err = stackutils.ValidateStackFiles(config.stack, securitySettings, config.FileService)
validStackFn := stackutils.IsValidStackFileAdapter(securitySettings)
err = stackutils.ValidateStackFiles(config.stack, validStackFn, config.FileService)
if err != nil {
return err
}
}
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
}

View File

@@ -2,13 +2,14 @@ package deployments
import (
"fmt"
"log"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type SwarmStackDeploymentConfig struct {
@@ -60,10 +61,16 @@ func (config *SwarmStackDeploymentConfig) GetUsername() string {
func (config *SwarmStackDeploymentConfig) Deploy() error {
if config.FileService == nil || config.StackDeployer == nil {
log.Println("[deployment, swarm] file service or stack deployer is not initialised")
log.Error().Msg("file service or stack deployer is not initialised")
return errors.New("file service or stack deployer cannot be nil")
}
err := stackutils.ValidateStackFiles(config.stack, stackutils.IsValidBuildContext, config.FileService)
if err != nil {
return err
}
isAdminOrEndpointAdmin, err := stackutils.UserIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
if err != nil {
return errors.Wrap(err, "failed to validate user admin privileges")
@@ -72,7 +79,9 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
settings := &config.endpoint.SecuritySettings
if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin {
err = stackutils.ValidateStackFiles(config.stack, settings, config.FileService)
validStackFn := stackutils.IsValidStackFileAdapter(settings)
err = stackutils.ValidateStackFiles(config.stack, validStackFn, config.FileService)
if err != nil {
return err
}

View File

@@ -1,16 +1,19 @@
package stackutils
import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/types"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
)
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
func loadComposeConfig(stackFileContent []byte) (*types.Config, error) {
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
if err != nil {
return err
return nil, err
}
composeConfigFile := types.ConfigFile{
@@ -22,14 +25,19 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
Environment: map[string]string{},
}
composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) {
return loader.Load(composeConfigDetails, func(options *loader.Options) {
options.SkipValidation = true
options.SkipInterpolation = true
})
if err != nil {
return err
}
}
func IsValidStackFileAdapter(securitySettings *portainer.EndpointSecuritySettings) func(*types.Config) error {
return func(composeConfig *types.Config) error {
return IsValidStackFile(composeConfig, securitySettings)
}
}
func IsValidStackFile(composeConfig *types.Config, securitySettings *portainer.EndpointSecuritySettings) error {
for key := range composeConfig.Services {
service := composeConfig.Services[key]
if !securitySettings.AllowBindMountsForRegularUsers {
@@ -64,17 +72,42 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
return nil
}
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
func ValidateStackFiles(stack *portainer.Stack, isValidFn func(content *types.Config) error, fileService portainer.FileService) error {
for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
if err != nil {
return errors.Wrap(err, "failed to get stack file content")
}
err = IsValidStackFile(stackContent, securitySettings)
composeConfig, err := loadComposeConfig(stackContent)
if err != nil {
return err
}
err = isValidFn(composeConfig)
if err != nil {
return errors.Wrap(err, "stack config file is invalid")
}
}
return nil
}
func IsValidBuildContext(composeConfig *types.Config) error {
for key := range composeConfig.Services {
service := composeConfig.Services[key]
if strings.HasPrefix(service.Build.Context, "/") || strings.Contains(service.Build.Context, "..") {
return errors.New("invalid build context")
}
driveLetter, _, ok := strings.Cut(service.Build.Context, ":")
driveLetter = strings.ToUpper(driveLetter)
if ok && len(driveLetter) == 1 && driveLetter >= "A" && driveLetter <= "Z" {
return errors.New("invalid build context")
}
}
return nil
}

View File

@@ -0,0 +1,66 @@
package stackutils
import (
"testing"
)
func composeFileWithBuild(p string) []byte {
return []byte(`
services:
service1:
build: ` + p)
}
func composeFileWithBuildContext(p string) []byte {
return []byte(`
services:
service1:
build:
context: ` + p)
}
func TestIsValidBuildContext(t *testing.T) {
var validPaths = []string{
".",
"./portainer",
`portainer\agent`,
"portainer",
" portainer",
"https://github.com/portainer/portainer",
"git@github.com/portainer/portainer.git",
}
var invalidPaths = []string{
"/etc/shadow",
`C:\Windows`,
"C:/Windows",
"./../../../../../etc/shadow",
" ..",
}
testFn := func(p string, mustFail bool) {
for _, fn := range []func(string) []byte{composeFileWithBuild, composeFileWithBuildContext} {
composeConfig, err := loadComposeConfig(fn(p))
if err != nil {
t.Fatalf("failed to load compose config: %v", err)
}
err = IsValidBuildContext(composeConfig)
if mustFail && err == nil {
t.Errorf("expected '%s' to be an invalid build context", p)
}
if !mustFail && err != nil {
t.Errorf("expected '%s' to be a valid build context", p)
}
}
}
for _, p := range validPaths {
testFn(p, false)
}
for _, p := range invalidPaths {
testFn(p, true)
}
}

View File

@@ -474,7 +474,7 @@ angular.module('portainer.docker').controller('ServiceController', [
'';
if (oldCredSpecId && !credSpecId) {
delete config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec;
} else if (credSpec && oldCredSpecId !== credSpec) {
} else if (oldCredSpecId !== credSpec) {
config.TaskTemplate.ContainerSpec.Privileges = {
...(config.TaskTemplate.ContainerSpec.Privileges || {}),
CredentialSpec: {
@@ -854,10 +854,6 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.filterConfigs = filterConfigs;
function filterConfigs(configs) {
if (!configs) {
return [];
}
return configs.filter((config) => $scope.service.ServiceConfigs.every((serviceConfig) => config.Id !== serviceConfig.Id));
}

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.19.4",
"version": "2.19.2",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
@@ -231,4 +231,4 @@
"**/moment": "^2.21.0"
},
"browserslist": "last 2 versions"
}
}

View File

@@ -126,10 +126,7 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O
command.WithHost(options.Host)
}
var stderr bytes.Buffer
args := []string{}
args = append(args, command.ToArgs()...)
args := append([]string{}, command.ToArgs()...)
cmd := exec.Command(program, args...)
cmd.Dir = options.WorkingDir
@@ -150,7 +147,8 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O
Interface("env", cmd.Env).
Msg("run command")
cmd.Stderr = &stderr
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
output, err := cmd.Output()
if err != nil {