diff --git a/README.md b/README.md index 8012ae59..65f31687 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Eigenlayer is a setup wizard for EigenLayer Node Software. The tool installs, ma - [Updating with explicit version](#updating-with-explicit-version) - [Updating with commit hash](#updating-with-commit-hash) - [Updating options](#updating-options) + - [Backup](#backup) + - [List backups](#list-backups) - [Uninstalling AVS Node Software](#uninstalling-avs-node-software) - [List installed instances](#list-installed-instances) - [Run an AVS instance](#run-an-avs-instance) @@ -263,6 +265,37 @@ eigenlayer update mock-avs-default a3406616b848164358fdd24465b8eecda5f5ae34 The `--no-prompt` flag is available to skip the options prompt, also the dynamic flags `--option.` are available to set the option values, like in the `install` command. +## Backup + +To backup an installed AVS Node Software, use the `eigenlayer backup` command with the AVS instance ID as an argument, as follows: + +```bash +eigenlayer backup mock-avs-default +``` + +Output: + +```bash +INFO[0000] Backing up instance mock-avs-default +INFO[0000] Backing up instance data... +INFO[0000] Backup created with id: mock-avs-default-1696337650 +``` + +## List backups + +To list all the backups, use the `eigenlayer backup ls` command, as follows: + +```bash +eigenlayer backup ls +``` + +Output: + +```bash +AVS Instance ID TIMESTAMP SIZE (GB) +mock-avs-default 2023-10-01 08:00:00 0.000009 +``` + ## Uninstalling AVS Node Software When uninstalling AVS Node Software, it is stopped, disconnected from the Monitoring Stack, and removed from the data directory. To uninstall AVS Node Software, use the `eigenlayer uninstall` command with the AVS instance ID as an argument, as follows: diff --git a/cli/backup.go b/cli/backup.go new file mode 100644 index 00000000..9bdecb11 --- /dev/null +++ b/cli/backup.go @@ -0,0 +1,34 @@ +package cli + +import ( + "github.com/NethermindEth/eigenlayer/pkg/daemon" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func BackupCmd(d daemon.Daemon) *cobra.Command { + var instanceId string + cmd := cobra.Command{ + Use: "backup ", + Short: "Backup an instance", + Long: "Backup an instance saving the data into a tarball file. To list backups, use 'eigenlayer backup ls'.", + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + instanceId = args[0] + }, + RunE: func(cmd *cobra.Command, args []string) error { + backupId, err := d.Backup(instanceId) + if err != nil { + return err + } + log.Info("Backup created with id: ", backupId) + return nil + }, + } + + // Add ls subcommand + lsCmd := BackupLsCmd(d) + cmd.AddCommand(lsCmd) + + return &cmd +} diff --git a/cli/backup_ls.go b/cli/backup_ls.go new file mode 100644 index 00000000..871912d6 --- /dev/null +++ b/cli/backup_ls.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/NethermindEth/eigenlayer/pkg/daemon" + "github.com/spf13/cobra" + "kythe.io/kythe/go/util/datasize" +) + +func BackupLsCmd(d daemon.Daemon) *cobra.Command { + cmd := cobra.Command{ + Use: "ls", + Short: "List backups", + Long: "List backups showing all backups and their details.", + RunE: func(cmd *cobra.Command, args []string) error { + backups, err := d.BackupList() + if err != nil { + return err + } + printBackupTable(backups, cmd.OutOrStdout()) + return nil + }, + } + return &cmd +} + +func printBackupTable(backups []daemon.BackupInfo, out io.Writer) { + w := tabwriter.NewWriter(out, 0, 0, 4, ' ', 0) + fmt.Fprintln(w, "AVS Instance ID\tTIMESTAMP\tSIZE\t") + for _, b := range backups { + fmt.Fprintln(w, backupTableItem{ + instance: b.Instance, + timestamp: b.Timestamp.Format(time.DateTime), + size: datasize.Size(b.SizeBytes).String(), + }) + } + w.Flush() +} + +type backupTableItem struct { + instance string + timestamp string + size string +} + +func (b backupTableItem) String() string { + return fmt.Sprintf("%s\t%s\t%s\t", b.instance, b.timestamp, b.size) +} diff --git a/cli/root.go b/cli/root.go index d701e15f..506f2647 100644 --- a/cli/root.go +++ b/cli/root.go @@ -23,6 +23,7 @@ func RootCmd(d daemon.Daemon, p prompter.Prompter) *cobra.Command { LogsCmd(d), InitMonitoringCmd(d), CleanMonitoringCmd(d), + BackupCmd(d), UpdateCmd(d, p), ) cmd.CompletionOptions.DisableDefaultCmd = true diff --git a/cli/update.go b/cli/update.go index 132fd23e..d17227b1 100644 --- a/cli/update.go +++ b/cli/update.go @@ -22,6 +22,7 @@ func UpdateCmd(d daemon.Daemon, p prompter.Prompter) *cobra.Command { version string commit string noPrompt bool + backup bool help bool yes bool ) @@ -158,7 +159,14 @@ Options of the new version can be specified using the --option. fla } } - // TODO: backup current instance + // Backup instance + if backup { + backupId, err := d.Backup(instanceId) + if err != nil { + return err + } + log.Info("Backup created with id: ", backupId) + } // Uninstall current instance err = uninstallPackage(d, instanceId) @@ -197,6 +205,7 @@ Options of the new version can be specified using the --option. fla cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "disable command prompts, and all options should be passed using command flags.") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts.") + cmd.Flags().BoolVar(&backup, "backup", false, "backup current instance before updating.") return &cmd } diff --git a/cli/update_test.go b/cli/update_test.go index d1091779..cb7e9cca 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "testing" + "time" daemonMock "github.com/NethermindEth/eigenlayer/cli/mocks" prompterMock "github.com/NethermindEth/eigenlayer/cli/prompter/mocks" @@ -183,6 +184,104 @@ func TestUpdate(t *testing.T) { ) }, }, + { + name: "update with backup", + args: []string{instanceId, "--backup"}, + err: nil, + mocker: func(ctrl *gomock.Controller, d *daemonMock.MockDaemon, p *prompterMock.MockPrompter) { + oldOption := daemonMock.NewMockOption(ctrl) + newOption := daemonMock.NewMockOption(ctrl) + mergedOption := daemonMock.NewMockOption(ctrl) + + oldOption.EXPECT().IsSet().Return(true) + oldOption.EXPECT().Value().Return("old-value", nil) + oldOption.EXPECT().Name().Return("old-option").Times(3) + mergedOption.EXPECT().IsSet().Return(true).Times(2) + mergedOption.EXPECT().Value().Return("old-value", nil) + mergedOption.EXPECT().Name().Return("old-option").Times(2) + mergedOption.EXPECT().Default().Return("default-value") + mergedOption.EXPECT().Help().Return("option help") + + gomock.InOrder( + d.EXPECT().PullUpdate(instanceId, daemon.PullTarget{}).Return(daemon.PullUpdateResult{ + Name: "mock-avs", + Tag: "default", + Url: common.MockAvsPkg.Repo(), + Profile: "option-returner", + OldVersion: "v5.4.0", + NewVersion: common.MockAvsPkg.Version(), + OldCommit: "b64c50c15e53ae7afebbdbe210b834d1ee471043", + NewCommit: common.MockAvsPkg.CommitHash(), + HasPlugin: true, + OldOptions: []daemon.Option{oldOption}, + NewOptions: []daemon.Option{newOption}, + MergedOptions: []daemon.Option{mergedOption}, + HardwareRequirements: daemon.HardwareRequirements{ + MinCPUCores: 2, + MinRAM: 2048, + MinFreeSpace: 5120, + StopIfRequirementsAreNotMet: true, + }, + }, nil), + d.EXPECT().Backup(instanceId).Return(fmt.Sprintf("%s-%d", instanceId, time.Now().Unix()), nil), + d.EXPECT().Uninstall(instanceId).Return(nil), + d.EXPECT().Install(daemon.InstallOptions{ + Name: "mock-avs", + Tag: "default", + URL: common.MockAvsPkg.Repo(), + Profile: "option-returner", + Version: common.MockAvsPkg.Version(), + Commit: common.MockAvsPkg.CommitHash(), + Options: []daemon.Option{mergedOption}, + }).Return(instanceId, nil), + p.EXPECT().Confirm("Run the new instance now?").Return(true, nil), + d.EXPECT().Run(instanceId), + ) + }, + }, + { + name: "update backup error", + args: []string{instanceId, "--backup"}, + err: assert.AnError, + mocker: func(ctrl *gomock.Controller, d *daemonMock.MockDaemon, p *prompterMock.MockPrompter) { + oldOption := daemonMock.NewMockOption(ctrl) + newOption := daemonMock.NewMockOption(ctrl) + mergedOption := daemonMock.NewMockOption(ctrl) + + oldOption.EXPECT().IsSet().Return(true) + oldOption.EXPECT().Value().Return("old-value", nil) + oldOption.EXPECT().Name().Return("old-option").Times(3) + mergedOption.EXPECT().IsSet().Return(true).Times(2) + mergedOption.EXPECT().Value().Return("old-value", nil) + mergedOption.EXPECT().Name().Return("old-option").Times(2) + mergedOption.EXPECT().Default().Return("default-value") + mergedOption.EXPECT().Help().Return("option help") + + gomock.InOrder( + d.EXPECT().PullUpdate(instanceId, daemon.PullTarget{}).Return(daemon.PullUpdateResult{ + Name: "mock-avs", + Tag: "default", + Url: common.MockAvsPkg.Repo(), + Profile: "option-returner", + OldVersion: "v5.4.0", + NewVersion: common.MockAvsPkg.Version(), + OldCommit: "b64c50c15e53ae7afebbdbe210b834d1ee471043", + NewCommit: common.MockAvsPkg.CommitHash(), + HasPlugin: true, + OldOptions: []daemon.Option{oldOption}, + NewOptions: []daemon.Option{newOption}, + MergedOptions: []daemon.Option{mergedOption}, + HardwareRequirements: daemon.HardwareRequirements{ + MinCPUCores: 2, + MinRAM: 2048, + MinFreeSpace: 5120, + StopIfRequirementsAreNotMet: true, + }, + }, nil), + d.EXPECT().Backup(instanceId).Return("", assert.AnError), + ) + }, + }, { name: "invalid arguments, instance id is required", args: []string{}, diff --git a/cmd/eigenlayer/main.go b/cmd/eigenlayer/main.go index 634b64ca..cae372c7 100644 --- a/cmd/eigenlayer/main.go +++ b/cmd/eigenlayer/main.go @@ -5,6 +5,7 @@ import ( "github.com/NethermindEth/eigenlayer/cli" "github.com/NethermindEth/eigenlayer/cli/prompter" + "github.com/NethermindEth/eigenlayer/internal/backup" "github.com/NethermindEth/eigenlayer/internal/commands" "github.com/NethermindEth/eigenlayer/internal/compose" "github.com/NethermindEth/eigenlayer/internal/data" @@ -63,8 +64,11 @@ func main() { log.Fatal(err) } + // Backup manager + backupMgr := backup.NewBackupManager(fs, dataDir, dockerManager) + // Initialize daemon - daemon, err := daemon.NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := daemon.NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) if err != nil { log.Fatal(err) } diff --git a/e2e/backup_test.go b/e2e/backup_test.go new file mode 100644 index 00000000..2be09cc5 --- /dev/null +++ b/e2e/backup_test.go @@ -0,0 +1,83 @@ +package e2e + +import ( + "regexp" + "testing" + "time" + + "github.com/NethermindEth/eigenlayer/internal/common" + "github.com/stretchr/testify/assert" +) + +func TestBackupInstance(t *testing.T) { + // Test context + var ( + backupErr error + start time.Time + ) + // Build test case + e2eTest := newE2ETestCase( + t, + // Arrange + func(t *testing.T, egnPath string) error { + start = time.Now() + err := buildMockAvsImagesLatest(t) + if err != nil { + return err + } + // Install latest version + return runCommand(t, egnPath, "install", "--profile", "option-returner", "--no-prompt", "--yes", "--version", common.MockAvsPkg.Version(), "--option.test-option-hidden", "12345678", "--option.test-option-enum-hidden", "option3", common.MockAvsPkg.Repo()) + }, + // Act + func(t *testing.T, egnPath string) { + backupErr = runCommand(t, egnPath, "backup", "mock-avs-default") + }, + // Assert + func(t *testing.T) { + assert.NoError(t, backupErr, "backup command should succeed") + checkBackupExist(t, "mock-avs-default", start, time.Now()) + }, + ) + // Run test case + e2eTest.run() +} + +func TestBackupList(t *testing.T) { + // Test context + var ( + out []byte + backupErr error + ) + // Build test case + e2eTest := newE2ETestCase( + t, + // Arrange + func(t *testing.T, egnPath string) error { + err := buildMockAvsImagesLatest(t) + if err != nil { + return err + } + // Install latest version + err = runCommand(t, egnPath, "install", "--profile", "option-returner", "--no-prompt", "--yes", "--version", common.MockAvsPkg.Version(), "--option.test-option-hidden", "12345678", "--option.test-option-enum-hidden", "option3", common.MockAvsPkg.Repo()) + if err != nil { + return err + } + return runCommand(t, egnPath, "backup", "mock-avs-default") + }, + // Act + func(t *testing.T, egnPath string) { + out, backupErr = runCommandOutput(t, egnPath, "backup", "ls") + }, + // Assert + func(t *testing.T) { + t.Log(string(out)) + assert.NoError(t, backupErr, "backup ls command should succeed") + assert.Regexp(t, regexp.MustCompile( + `AVS Instance ID TIMESTAMP SIZE +mock-avs-default .* 9KiB`), + string(out)) + }, + ) + // Run test case + e2eTest.run() +} diff --git a/e2e/checks.go b/e2e/checks.go index ba9b3f27..42dcd0a8 100644 --- a/e2e/checks.go +++ b/e2e/checks.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "net/url" + "os" "path/filepath" "slices" "testing" "time" + "github.com/NethermindEth/eigenlayer/internal/data" "github.com/NethermindEth/eigenlayer/internal/env" "github.com/NethermindEth/eigenlayer/pkg/monitoring" "github.com/cenkalti/backoff" @@ -364,3 +366,33 @@ func checkEnvTargets(t *testing.T, instanceId string, targets ...string) { assert.Equal(t, envData[target], "\"\"", "target %s should be empty string", target) } } + +// checkBackupExist checks that a backup exists for the given instanceId between +// the given times a and b, inclusive. +func checkBackupExist(t *testing.T, instanceId string, a, b time.Time) { + dataDir, err := dataDirPath() + require.NoError(t, err) + backupsDir := filepath.Join(dataDir, "backup") + + dirFiles, err := os.ReadDir(backupsDir) + require.NoError(t, err) + + var found bool + for _, f := range dirFiles { + if f.IsDir() { + continue + } + i, timestamp, err := data.ParseBackupName(filepath.Base(f.Name())) + if err != nil { + continue + } + if i == instanceId && + ((timestamp.After(a)) && timestamp.Before(b) || + timestamp.Equal(a) || + timestamp.Equal(b)) { + found = true + break + } + } + assert.True(t, found) +} diff --git a/go.mod b/go.mod index 19b595f2..c279bb73 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 golang.org/x/mod v0.12.0 gopkg.in/yaml.v3 v3.0.1 + kythe.io v0.0.63 ) require ( @@ -69,7 +70,7 @@ require ( github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.1.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect diff --git a/go.sum b/go.sum index 367e123b..1b5b0890 100644 --- a/go.sum +++ b/go.sum @@ -289,8 +289,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -684,7 +684,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -699,6 +698,10 @@ 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= +kythe.io v0.0.63 h1:86+Nj3RDsjxvuKveLS77BXFs5wyxwPsEkOdEyOJh5JM= +kythe.io v0.0.63/go.mod h1:w8cshJa+WLxzqrXaN/OSm2HzLXrgFRXUW4sZUZVGSow= 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= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/backup/config.go b/internal/backup/config.go new file mode 100644 index 00000000..fd901183 --- /dev/null +++ b/internal/backup/config.go @@ -0,0 +1,19 @@ +package backup + +import ( + "io" + + "gopkg.in/yaml.v3" +) + +type backupConfig struct { + Prefix string `yaml:"prefix"` + Volumes []string `yaml:"volumes"` +} + +func (b *backupConfig) Save(f io.Writer) error { + encoder := yaml.NewEncoder(f) + encoder.SetIndent(2) + defer encoder.Close() + return encoder.Encode(b) +} diff --git a/internal/backup/config_test.go b/internal/backup/config_test.go new file mode 100644 index 00000000..cc0adc17 --- /dev/null +++ b/internal/backup/config_test.go @@ -0,0 +1,41 @@ +package backup + +import ( + "os" + "testing" + + "github.com/NethermindEth/eigenlayer/internal/backup/testdata" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSave(t *testing.T) { + afs := afero.NewMemMapFs() + outFilePath := "/config.yml" + + outFile, err := afs.OpenFile(outFilePath, os.O_CREATE|os.O_RDWR, 0o644) + require.NoError(t, err) + defer outFile.Close() + + config := backupConfig{ + Prefix: "volumes/instance-1", + Volumes: []string{ + "/path/to/volume1", + "/path/to/volume/2.txt", + }, + } + + // Save the config to the file + err = config.Save(outFile) + require.NoError(t, err) + + // Read the file + actual, err := afero.ReadFile(afs, outFilePath) + require.NoError(t, err) + + // Assert + expected, err := testdata.TestData.ReadFile("data/config.yml") + require.NoError(t, err) + assert.Equal(t, expected, actual) +} diff --git a/internal/backup/manager.go b/internal/backup/manager.go new file mode 100644 index 00000000..689385cc --- /dev/null +++ b/internal/backup/manager.go @@ -0,0 +1,193 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/NethermindEth/eigenlayer/internal/data" + "github.com/NethermindEth/eigenlayer/internal/docker" + "github.com/NethermindEth/eigenlayer/internal/utils" + "github.com/compose-spec/compose-go/types" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +const ( + SnapshotterVersion = "v0.1.0" + SnapshotterRepo = "github.com/NethermindEth/docker-volumes-snapshotter" + SnapshotterRemoteContext = SnapshotterRepo + ".git#" + SnapshotterVersion + SnapshotterImage = "eigenlayer-snapshotter:" + SnapshotterVersion +) + +type BackupInfo struct { + Instance string + Timestamp time.Time + SizeBytes uint64 +} + +type BackupManager struct { + dataDir *data.DataDir + dockerMgr *docker.DockerManager + fs afero.Fs +} + +func NewBackupManager(fs afero.Fs, dataDir *data.DataDir, dockerMgr *docker.DockerManager) *BackupManager { + return &BackupManager{ + dataDir: dataDir, + dockerMgr: dockerMgr, + fs: fs, + } +} + +// BackupInstance creates a backup of the instance with the given ID. +func (b *BackupManager) BackupInstance(instanceId string) (string, error) { + if !b.dataDir.HasInstance(instanceId) { + return "", fmt.Errorf("%w: instance %s", data.ErrInstanceNotFound, instanceId) + } + instance, err := b.dataDir.Instance(instanceId) + if err != nil { + return "", err + } + log.Info("Backing up instance ", instanceId) + if err := b.buildSnapshotterImage(); err != nil { + return "", err + } + instanceProject, err := instance.ComposeProject() + if err != nil { + return "", err + } + + backupId := data.BackupId{ + InstanceId: instanceId, + Timestamp: time.Now(), + } + backup, err := b.dataDir.InitBackup(backupId) + if err != nil { + return "", err + } + + // Add volumes of each service + for _, service := range instanceProject.Services { + err := b.backupInstanceServiceVolumes(service, backup) + if err != nil { + return "", err + } + } + + // Add instance data + err = b.backupInstanceData(instanceId, backup) + if err != nil { + return "", err + } + + return backup.BackupId.String(), nil +} + +// BackupList returns a list of all backups. +func (b *BackupManager) BackupList() ([]BackupInfo, error) { + // Get the list of backup paths from the data dir + backups, err := b.dataDir.BackupList() + if err != nil { + return nil, err + } + // Build backup info for each backup + var backupsInfo []BackupInfo + for _, b := range backups { + size, err := b.Size() + if err != nil { + return nil, err + } + backupsInfo = append(backupsInfo, BackupInfo{ + Instance: b.InstanceId(), + Timestamp: b.Timestamp(), + SizeBytes: size, + }) + } + return backupsInfo, nil +} + +func (b *BackupManager) backupInstanceData(instanceId string, backup *data.Backup) (err error) { + log.Info("Backing up instance data...") + backupPath := backup.Path() + tarFile, err := b.fs.OpenFile(backupPath, os.O_RDWR, 0o644) + if err != nil { + return err + } + defer tarFile.Close() + + err = utils.TarPrepareToAppend(tarFile) + if err != nil { + return err + } + instancePath, err := b.dataDir.InstancePath(instanceId) + if err != nil { + return err + } + return utils.TarAddDir(instancePath, filepath.Join("data"), tarFile) +} + +func (b *BackupManager) backupInstanceServiceVolumes(service types.ServiceConfig, backup *data.Backup) (err error) { + if len(service.Volumes) == 0 { + return nil + } + log.Infof("Backing up %d volumes from service \"%s\"...", len(service.Volumes), service.Name) + volumes := make([]string, 0, len(service.Volumes)) + for _, v := range service.Volumes { + volumes = append(volumes, v.Target) + } + config := backupConfig{ + Prefix: snapshotterConfigPrefix(service.Name), + Volumes: volumes, + } + f, err := afero.TempFile(b.fs, os.TempDir(), "eigenlayer-backup-config-*.yaml") + if err != nil { + return err + } + defer f.Close() + err = config.Save(f) + if err != nil { + return err + } + err = b.dockerMgr.Run("eigenlayer-snapshotter", docker.RunOptions{ + AutoRemove: true, + Mounts: []docker.Mount{ + { + Type: docker.VolumeTypeBind, + Source: f.Name(), + Target: "/snapshotter.yml", + }, + { + Type: docker.VolumeTypeBind, + Source: backup.Path(), + Target: "/backup.tar", + }, + }, + VolumesFrom: []string{service.ContainerName}, + }) + if err != nil { + return fmt.Errorf("snapshotter failed with error: %w", err) + } + return nil +} + +func (b *BackupManager) buildSnapshotterImage() error { + ok, err := b.dockerMgr.ImageExist(SnapshotterImage) + if err != nil { + return err + } + if !ok { + log.Infof("Building snapshotter image \"%s\" from \"%s\" ...", SnapshotterImage, SnapshotterRemoteContext) + log.Infof("To learn more about the snapshotter, visit https://%s/tree/%s", SnapshotterRepo, SnapshotterVersion) + err = b.dockerMgr.BuildImageFromURI(SnapshotterRemoteContext, SnapshotterImage, nil) + if err != nil { + return fmt.Errorf("%w: %s", data.ErrCreatingBackup, err.Error()) + } + } + return nil +} + +func snapshotterConfigPrefix(service string) string { + return filepath.Join("volumes", service) +} diff --git a/internal/backup/testdata/data/config.yml b/internal/backup/testdata/data/config.yml new file mode 100644 index 00000000..c3ee3c2c --- /dev/null +++ b/internal/backup/testdata/data/config.yml @@ -0,0 +1,4 @@ +prefix: volumes/instance-1 +volumes: + - /path/to/volume1 + - /path/to/volume/2.txt diff --git a/internal/backup/testdata/testdata.go b/internal/backup/testdata/testdata.go new file mode 100644 index 00000000..cdd1123a --- /dev/null +++ b/internal/backup/testdata/testdata.go @@ -0,0 +1,6 @@ +package testdata + +import "embed" + +//go:embed all:* +var TestData embed.FS diff --git a/internal/data/backup.go b/internal/data/backup.go new file mode 100644 index 00000000..a5c7f92a --- /dev/null +++ b/internal/data/backup.go @@ -0,0 +1,82 @@ +package data + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "time" + + "github.com/spf13/afero" +) + +type BackupId struct { + InstanceId string + Timestamp time.Time +} + +func (b *BackupId) String() string { + return fmt.Sprintf("%s-%d", b.InstanceId, b.Timestamp.Unix()) +} + +type Backup struct { + BackupId + path string + fs afero.Fs +} + +// NewBackup creates a new Backup instance from the given path. +func NewBackup(fs afero.Fs, path string) (*Backup, error) { + backupFileName := filepath.Base(path) + instanceId, timestamp, err := ParseBackupName(backupFileName) + if err != nil { + return nil, err + } + return &Backup{ + BackupId: BackupId{ + InstanceId: instanceId, + Timestamp: timestamp, + }, + path: path, + fs: fs, + }, nil +} + +// Path returns the path of the backup. +func (b *Backup) Path() string { + return b.path +} + +// InstanceId returns the instance ID of the backup. +func (b *Backup) InstanceId() string { + return b.BackupId.InstanceId +} + +// Timestamp returns the timestamp of the backup. +func (b *Backup) Timestamp() time.Time { + return b.BackupId.Timestamp +} + +// Size returns the size of the backup in bytes. +func (b *Backup) Size() (uint64, error) { + bStat, err := b.fs.Stat(b.path) + if err != nil { + return 0, err + } + return uint64(bStat.Size()), nil +} + +func ParseBackupName(backupName string) (instanceId string, timestamp time.Time, err error) { + backupFileNameRegex := regexp.MustCompile(`^(?P.*)-(?P[0-9]+)\.tar$`) + match := backupFileNameRegex.FindStringSubmatch(backupName) + if len(match) != 3 { + return "", time.Time{}, fmt.Errorf("%w: %s", ErrInvalidBackupName, backupName) + } + instanceId = match[1] + timestampInt, err := strconv.ParseInt(match[2], 10, 64) + if err != nil { + return "", time.Time{}, fmt.Errorf("%w: %s", ErrInvalidBackupName, backupName) + } + timestamp = time.Unix(timestampInt, 0) + return instanceId, timestamp, nil +} diff --git a/internal/data/backup_test.go b/internal/data/backup_test.go new file mode 100644 index 00000000..17d8d4d0 --- /dev/null +++ b/internal/data/backup_test.go @@ -0,0 +1,60 @@ +package data + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseBackupName(t *testing.T) { + tc := []struct { + name string + backupName string + instanceId string + timestamp time.Time + err error + }{ + { + name: "valid backup name", + backupName: "mock-avs-default-1696317683.tar", + instanceId: "mock-avs-default", + timestamp: time.Unix(1696317683, 0), + err: nil, + }, + { + name: "no .tar file", + backupName: "mock-avs-default-1696317683", + instanceId: "", + timestamp: time.Time{}, + err: ErrInvalidBackupName, + }, + { + name: "without dash separator between instance ID and timestamp", + backupName: "mock-avs-default1696317683.tar", + instanceId: "", + timestamp: time.Time{}, + err: ErrInvalidBackupName, + }, + { + name: "invalid timestamp", + backupName: "mock-avs-default-1696317683a.tar", + instanceId: "", + timestamp: time.Time{}, + err: ErrInvalidBackupName, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + instanceId, timestamp, err := ParseBackupName(tt.backupName) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.instanceId, instanceId) + assert.Equal(t, tt.timestamp.Unix(), timestamp.Unix()) + } + }) + } +} diff --git a/internal/data/datadir.go b/internal/data/datadir.go index baa6221e..8c481e15 100644 --- a/internal/data/datadir.go +++ b/internal/data/datadir.go @@ -8,6 +8,7 @@ import ( "github.com/NethermindEth/eigenlayer/internal/locker" "github.com/NethermindEth/eigenlayer/internal/package_handler" + "github.com/NethermindEth/eigenlayer/internal/utils" "github.com/spf13/afero" ) @@ -15,6 +16,7 @@ const ( nodesDirName = "nodes" tempDir = "temp" pluginsDir = "plugin" + backupDir = "backup" ) const monitoringStackDirName = "monitoring" @@ -162,6 +164,95 @@ func (d *DataDir) TempPath(id string) (string, error) { return tempPath, nil } +func (d *DataDir) InitBackup(backupId BackupId) (*Backup, error) { + if err := d.initBackupDir(); err != nil { + return nil, err + } + return d.initBackup(backupId) +} + +// BackupList returns the list of paths to all the backups. Only .tar files are +// returned. +func (d *DataDir) BackupList() ([]*Backup, error) { + // Init backup dir + if err := d.initBackupDir(); err != nil { + return nil, err + } + // Get all files in backup dir + dirItems, err := afero.ReadDir(d.fs, filepath.Join(d.path, backupDir)) + if err != nil { + return nil, err + } + // Filter .tar files + var backups []*Backup + for _, dirItem := range dirItems { + if dirItem.IsDir() { + continue + } + if filepath.Ext(dirItem.Name()) == ".tar" { + b, err := NewBackup(d.fs, filepath.Join(d.path, backupDir, dirItem.Name())) + if err != nil { + return nil, err + } + backups = append(backups, b) + } + } + return backups, nil +} + +func (d *DataDir) initBackup(backupId BackupId) (*Backup, error) { + backupPath, err := d.backupPath(backupId) + if err != nil { + return nil, err + } + + ok, err := d.hasBackup(backupId) + if err != nil { + return nil, err + } + if ok { + return nil, fmt.Errorf("%w: %s", ErrBackupAlreadyExists, backupId) + } + + err = utils.TarInit(d.fs, backupPath) + if err != nil { + return nil, err + } + + return &Backup{ + BackupId: backupId, + path: backupPath, + fs: d.fs, + }, nil +} + +func (d *DataDir) hasBackup(backupId BackupId) (bool, error) { + backupPath, err := d.backupPath(backupId) + if err != nil { + return false, err + } + return afero.Exists(d.fs, backupPath) +} + +func (d *DataDir) backupPath(backupId BackupId) (string, error) { + return filepath.Join(d.path, backupDir, backupId.String()+".tar"), nil +} + +func (d *DataDir) initBackupDir() error { + backupDirPath := filepath.Join(d.path, backupDir) + ok, err := afero.DirExists(d.fs, backupDirPath) + if err != nil { + return err + } + if !ok { + err = d.fs.MkdirAll(backupDirPath, 0o755) + if err != nil { + return err + } + } + return nil +} + // MonitoringStack checks if a monitoring stack directory exists in the data directory. // If the directory does not exist, it creates it and initializes a new MonitoringStack instance. // If the directory exists, it simply returns a new MonitoringStack instance. diff --git a/internal/data/datadir_test.go b/internal/data/datadir_test.go index 632c376b..da684ba1 100644 --- a/internal/data/datadir_test.go +++ b/internal/data/datadir_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/NethermindEth/eigenlayer/internal/common" + "github.com/NethermindEth/eigenlayer/internal/locker" "github.com/NethermindEth/eigenlayer/internal/locker/mocks" "github.com/golang/mock/gomock" "github.com/spf13/afero" @@ -77,10 +79,12 @@ func TestDataDir_Instance(t *testing.T) { type testCase struct { name string + locker locker.Locker instanceId string path string instance *Instance err error + mockCtrl *gomock.Controller } ts := []testCase{ func() testCase { @@ -99,8 +103,12 @@ func TestDataDir_Instance(t *testing.T) { if err != nil { t.Fatal(err) } + ctrl := gomock.NewController(t) + locker := mocks.NewMockLocker(ctrl) + locker.EXPECT().New(filepath.Join(path, nodesDirName, "mock-avs-default", ".lock")).Return(locker) return testCase{ name: "valid instance", + locker: locker, instanceId: "mock-avs-default", path: path, instance: &Instance{ @@ -111,8 +119,10 @@ func TestDataDir_Instance(t *testing.T) { Profile: "option-returner", path: filepath.Join(path, nodesDirName, "mock-avs-default"), fs: fs, + locker: locker, }, - err: nil, + err: nil, + mockCtrl: ctrl, } }(), func() testCase { @@ -131,12 +141,16 @@ func TestDataDir_Instance(t *testing.T) { if err != nil { t.Fatal(err) } + ctrl := gomock.NewController(t) + locker := mocks.NewMockLocker(ctrl) return testCase{ name: "invalid instance, state without name field", + locker: locker, instanceId: "mock-avs-default", path: path, instance: nil, err: ErrInvalidInstance, + mockCtrl: ctrl, } }(), func() testCase { @@ -146,26 +160,23 @@ func TestDataDir_Instance(t *testing.T) { if err != nil { t.Fatal(err) } + ctrl := gomock.NewController(t) + locker := mocks.NewMockLocker(ctrl) return testCase{ name: "instance not found", + locker: locker, instanceId: "mock-avs-default", path: path, instance: nil, err: ErrInvalidInstanceDir, + mockCtrl: ctrl, } }(), } for _, tc := range ts { t.Run(tc.name, func(t *testing.T) { - // Create a mock locker - ctrl := gomock.NewController(t) - locker := mocks.NewMockLocker(ctrl) - - if tc.instance != nil { - tc.instance.locker = locker - } - - dataDir, err := NewDataDir(tc.path, fs, locker) + defer tc.mockCtrl.Finish() + dataDir, err := NewDataDir(tc.path, fs, tc.locker) assert.NoError(t, err) instance, err := dataDir.Instance(tc.instanceId) if tc.err != nil { @@ -698,6 +709,203 @@ func TestDataDir_TempPath(t *testing.T) { } } +func TestDataDir_initBackupDir(t *testing.T) { + tc := []struct { + name string + err error + setup func(*testing.T) *DataDir + }{ + { + name: "backup dir exists", + err: nil, + setup: func(t *testing.T) *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(testDir, 0o755) + require.NoError(t, err) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + { + name: "backup dir does not exist", + err: nil, + setup: func(t *testing.T) *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(filepath.Join(testDir, backupDir), 0o755) + require.NoError(t, err) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + d := tt.setup(t) + err := d.initBackupDir() + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + exists, err := afero.DirExists(d.fs, filepath.Join(d.path, backupDir)) + require.NoError(t, err) + assert.True(t, exists) + } + }) + } +} + +func TestDataDir_hasBackup(t *testing.T) { + backupId := BackupId{ + InstanceId: "mock-avs-default", + Timestamp: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + } + tc := []struct { + name string + setup func() *DataDir + ok bool + err error + }{ + { + name: "backup dir does not exist", + ok: false, + err: nil, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + { + name: "backup file does not exist", + ok: false, + err: nil, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(filepath.Join(testDir, backupDir), 0o755) + require.NoError(t, err) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + { + name: "backup file exists", + ok: true, + err: nil, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(filepath.Join(testDir, backupDir), 0o755) + require.NoError(t, err) + file, err := fs.Create(filepath.Join(testDir, backupDir, "mock-avs-default-1672531200.tar")) + require.NoError(t, err) + require.NoError(t, file.Close()) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + d := tt.setup() + ok, err := d.hasBackup(backupId) + if tt.err != nil { + require.Error(t, err) + assert.False(t, ok) + } else { + require.NoError(t, err) + assert.Equal(t, tt.ok, ok) + } + }) + } +} + +func TestDataDir_InitBackup(t *testing.T) { + backupId := BackupId{ + InstanceId: "mock-avs-default", + Timestamp: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + } + tc := []struct { + name string + err error + setup func() *DataDir + }{ + { + name: "success, backup dir does not exist", + err: nil, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + { + name: "success, backup file does not exist", + err: nil, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(filepath.Join(testDir, backupDir), 0o755) + require.NoError(t, err) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + { + name: "error, backup file exists", + err: ErrBackupAlreadyExists, + setup: func() *DataDir { + fs := afero.NewMemMapFs() + testDir := t.TempDir() + err := fs.MkdirAll(filepath.Join(testDir, backupDir), 0o755) + require.NoError(t, err) + file, err := fs.Create(filepath.Join(testDir, backupDir, "mock-avs-default-1672531200.tar")) + require.NoError(t, err) + require.NoError(t, file.Close()) + return &DataDir{ + path: testDir, + fs: fs, + } + }, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + d := tt.setup() + b, err := d.initBackup(backupId) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + require.NotNil(t, b) + assert.Equal(t, backupId, b.BackupId) + bStat, err := d.fs.Stat(b.path) + require.NoError(t, err) + require.Equal(t, bStat.Mode(), os.FileMode(0o644)) + require.Equal(t, bStat.Size(), int64(1024)) + } + }) + } +} + func TestMonitoringStack(t *testing.T) { // Create a memory filesystem fs := afero.NewMemMapFs() diff --git a/internal/data/error.go b/internal/data/error.go index 21a4fba6..b71917ce 100644 --- a/internal/data/error.go +++ b/internal/data/error.go @@ -15,4 +15,7 @@ var ( ErrReadingFile = errors.New("failed reading file") ErrWritingFile = errors.New("failed writing file") ErrStackNotInitialized = errors.New("stack not initialized") + ErrBackupAlreadyExists = errors.New("backup already exists") + ErrCreatingBackup = errors.New("failed creating backup") + ErrInvalidBackupName = errors.New("invalid backup name") ) diff --git a/internal/data/instance.go b/internal/data/instance.go index 6f694114..dbf31a7f 100644 --- a/internal/data/instance.go +++ b/internal/data/instance.go @@ -6,12 +6,15 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "path/filepath" "github.com/NethermindEth/eigenlayer/internal/env" "github.com/NethermindEth/eigenlayer/internal/locker" "github.com/NethermindEth/eigenlayer/internal/profile" + "github.com/compose-spec/compose-go/cli" + "github.com/compose-spec/compose-go/types" "github.com/spf13/afero" "gopkg.in/yaml.v3" ) @@ -72,9 +75,8 @@ func (p *Plugin) validate() error { // state.json file and validates it. func newInstance(path string, fs afero.Fs, locker locker.Locker) (*Instance, error) { i := Instance{ - path: path, - fs: fs, - locker: locker, + path: path, + fs: fs, } stateFile, err := i.fs.Open(filepath.Join(i.path, "state.json")) if err != nil { @@ -102,6 +104,7 @@ func newInstance(path string, fs afero.Fs, locker locker.Locker) (*Instance, err if err != nil { return nil, err } + i.locker = locker.New(filepath.Join(path, ".lock")) return &i, nil } @@ -226,6 +229,23 @@ func (i *Instance) ComposePath() string { return filepath.Join(i.path, "docker-compose.yml") } +// ComposeProject returns the compose project of the instance. +func (i *Instance) ComposeProject() (*types.Project, error) { + // Load instance environment variables + instanceEnv, err := i.Env() + if err != nil { + return nil, err + } + // Build project options with the instance environment + projectOptions, err := cli.NewProjectOptions([]string{i.ComposePath()}) + if err != nil { + return nil, err + } + maps.Copy(projectOptions.Environment, instanceEnv) + // Load project from options + return cli.ProjectFromOptions(projectOptions) +} + // ProfileFile returns the data from the profile.yml file of the instance. func (i *Instance) ProfileFile() (*profile.Profile, error) { if err := i.lock(); err != nil { diff --git a/internal/data/instance_test.go b/internal/data/instance_test.go index d6a5486d..7c8c22ca 100644 --- a/internal/data/instance_test.go +++ b/internal/data/instance_test.go @@ -23,6 +23,7 @@ func TestNewInstance(t *testing.T) { name string path string instance *Instance + mocker func(*mocks.MockLocker) err error } ts := []testCase{ @@ -89,6 +90,10 @@ func TestNewInstance(t *testing.T) { Commit: common.MockAvsPkg.CommitHash(), Profile: "mainnet", path: testDir, + fs: fs, + }, + mocker: func(locker *mocks.MockLocker) { + locker.EXPECT().New(filepath.Join(testDir, ".lock")).Return(locker) }, err: nil, } @@ -146,8 +151,12 @@ func TestNewInstance(t *testing.T) { Plugin: &Plugin{ Image: common.PluginImage.FullImage(), }, + fs: fs, path: testDir, }, + mocker: func(locker *mocks.MockLocker) { + locker.EXPECT().New(filepath.Join(testDir, ".lock")).Return(locker) + }, err: nil, } }(), @@ -184,9 +193,11 @@ func TestNewInstance(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Create a mock locker ctrl := gomock.NewController(t) + defer ctrl.Finish() locker := mocks.NewMockLocker(ctrl) - if tc.instance != nil { - tc.instance.fs = fs + + if tc.mocker != nil { + tc.mocker(locker) tc.instance.locker = locker } @@ -395,3 +406,36 @@ func TestInstance_Env(t *testing.T) { }) } } + +func TestInstance_ComposeProject(t *testing.T) { + fs := afero.NewOsFs() + dir := testdata.SetupProfileFS(t, "option-returner", fs) + + ctrl := gomock.NewController(t) + l := mocks.NewMockLocker(ctrl) + defer ctrl.Finish() + gomock.InOrder( + l.EXPECT().Lock().Return(nil), + l.EXPECT().Locked().Return(true), + l.EXPECT().Unlock().Return(nil), + ) + + i := Instance{ + path: dir, + locker: l, + fs: fs, + } + p, err := i.ComposeProject() + require.NoError(t, err) + + // Check services + require.Len(t, p.Services, 1) + require.Equal(t, "main-service", p.Services[0].Name) + // Check main-service ports + mainService := p.Services[0] + require.Len(t, mainService.Ports, 1) + require.Equal(t, uint32(8080), mainService.Ports[0].Target) + require.Equal(t, "8080", mainService.Ports[0].Published) + // Check main-service container name + require.Equal(t, "main-service", mainService.ContainerName) +} diff --git a/internal/data/testdata/testdata.go b/internal/data/testdata/testdata.go index 89a17db4..e19e5e61 100644 --- a/internal/data/testdata/testdata.go +++ b/internal/data/testdata/testdata.go @@ -23,8 +23,7 @@ func SetupProfileFS(t *testing.T, instanceName string, afs afero.Fs) string { t.Fatalf("failed to setup instance filesystem: %v", err) } - tempPath, err := afero.TempDir(afs, "profile", "") - require.NoError(t, err) + tempPath := t.TempDir() err = fs.WalkDir(instanceFs, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 7045f2b1..a7ef98fd 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -332,29 +332,28 @@ func (d *DockerManager) NetworkDisconnect(container, network string) error { return d.dockerClient.NetworkDisconnect(context.Background(), network, container, false) } -// TODO: [REFACTOR] Remove this function if it's not used. // BuildFromURL build an image from a Git repository URI or HTTP/HTTPS context URI. -// func (d *DockerManager) BuildImageFromURI(remote string, tag string, buildArgs map[string]*string) (err error) { -// log.Debugf("Building image from %s", remote) -// buildResult, err := d.dockerClient.ImageBuild(context.Background(), nil, types.ImageBuildOptions{ -// RemoteContext: remote, -// BuildArgs: buildArgs, -// Tags: []string{tag}, -// Remove: true, -// ForceRemove: true, -// }) -// if err != nil { -// return err -// } -// defer buildResult.Body.Close() +func (d *DockerManager) BuildImageFromURI(remote string, tag string, buildArgs map[string]*string) (err error) { + log.Debugf("Building image from %s", remote) + buildResult, err := d.dockerClient.ImageBuild(context.Background(), nil, types.ImageBuildOptions{ + RemoteContext: remote, + BuildArgs: buildArgs, + Tags: []string{tag}, + Remove: true, + ForceRemove: true, + }) + if err != nil { + return err + } + defer buildResult.Body.Close() -// loadResult, err := d.dockerClient.ImageLoad(context.Background(), buildResult.Body, true) -// if err != nil { -// return err -// } -// defer loadResult.Body.Close() -// return nil -// } + loadResult, err := d.dockerClient.ImageLoad(context.Background(), buildResult.Body, true) + if err != nil { + return err + } + defer loadResult.Body.Close() + return nil +} // LoadImageContext loads an image context from a local path. func (d *DockerManager) LoadImageContext(path string) (io.ReadCloser, error) { @@ -419,6 +418,14 @@ func (d *DockerManager) ImageRemove(image string) error { return err } +type RunOptions struct { + Network string + Args []string + Mounts []Mount + VolumesFrom []string + AutoRemove bool +} + // Run is a method of DockerManager that handles running a Docker container from an image. // It creates the container from the specified image with the provided command arguments, // connects the created container to the specified network, then starts the container. @@ -426,11 +433,14 @@ func (d *DockerManager) ImageRemove(image string) error { // After the container starts, the function waits for the container to exit. // During the waiting process, it also listens for errors from the container. // If an error is received, it prints the container logs and returns the error. -func (d *DockerManager) Run(image string, network string, args []string, mounts []Mount) (err error) { +func (d *DockerManager) Run(image string, options RunOptions) (err error) { log.Debugf("Creating container from image %s", image) // Build mounts - hostConfig := &dockerCt.HostConfig{} - for _, mount := range mounts { + hostConfig := &dockerCt.HostConfig{ + VolumesFrom: options.VolumesFrom, + AutoRemove: options.AutoRemove, + } + for _, mount := range options.Mounts { switch mount.Type { case VolumeTypeBind, VolumeTypeVolume: hostConfig.Mounts = append(hostConfig.Mounts, mount.mount()) @@ -440,7 +450,7 @@ func (d *DockerManager) Run(image string, network string, args []string, mounts } // Create container createResponse, err := d.dockerClient.ContainerCreate(context.Background(), - &dockerCt.Config{Image: image, Cmd: args}, + &dockerCt.Config{Image: image, Cmd: options.Args}, hostConfig, nil, nil, "") if err != nil { return err @@ -448,6 +458,9 @@ func (d *DockerManager) Run(image string, network string, args []string, mounts // Ensure the container is removed after use defer func() { + if options.AutoRemove { + return + } log.Debugf("Removing container %s", createResponse.ID) removeErr := d.dockerClient.ContainerRemove(context.Background(), createResponse.ID, types.ContainerRemoveOptions{}) if removeErr != nil { @@ -461,9 +474,9 @@ func (d *DockerManager) Run(image string, network string, args []string, mounts } }() - if network != NetworkHost { - log.Debugf("Connecting container %s to network %s", createResponse.ID, network) - err = d.NetworkConnect(createResponse.ID, network) + if options.Network != "" && options.Network != NetworkHost { + log.Debugf("Connecting container %s to network %s", createResponse.ID, options.Network) + err = d.NetworkConnect(createResponse.ID, options.Network) if err != nil { return err } @@ -494,6 +507,18 @@ func (d *DockerManager) Run(image string, network string, args []string, mounts } } +// ImageExist checks if a specified Docker image exists. +func (d *DockerManager) ImageExist(image string) (bool, error) { + _, _, err := d.dockerClient.ImageInspectWithRaw(context.Background(), image) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + func containerLogs(dockerClient client.APIClient, containerID string) string { logsReader, err := dockerClient.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) if err != nil { diff --git a/internal/docker/docker_test.go b/internal/docker/docker_test.go index e29121da..9ebce524 100644 --- a/internal/docker/docker_test.go +++ b/internal/docker/docker_test.go @@ -25,6 +25,7 @@ import ( "github.com/NethermindEth/eigenlayer/internal/common" "github.com/NethermindEth/eigenlayer/internal/docker/mocks" + "github.com/NethermindEth/eigenlayer/internal/utils" ) // Image tests @@ -1162,124 +1163,123 @@ func TestNetworkDisconnect(t *testing.T) { } } -// TODO: [REFACTOR] Remove this function if it's not used. -// func TestBuildImageFromURI(t *testing.T) { -// type testCase struct { -// name string -// remote string -// tag string -// buildArgs map[string]*string -// setup func(*mocks.MockAPIClient) -// expectedError error -// } -// tests := []testCase{ -// { -// name: "success", -// remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// tag: "mock-avs-plugin", -// setup: func(dockerClient *mocks.MockAPIClient) { -// buildBody := io.NopCloser(bytes.NewReader([]byte{})) -// buildResponse := types.ImageBuildResponse{ -// Body: buildBody, -// } -// loadBody := io.NopCloser(bytes.NewReader([]byte{})) -// loadResponse := types.ImageLoadResponse{ -// Body: loadBody, -// } -// dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ -// RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// Tags: []string{"mock-avs-plugin"}, -// Remove: true, -// ForceRemove: true, -// }).Return(buildResponse, nil) -// dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(loadResponse, nil) -// }, -// expectedError: nil, -// }, -// func(t *testing.T) testCase { -// buildArgs := map[string]*string{ -// "key1": utils.StringPtr("value1"), -// "key2": utils.StringPtr("value2"), -// } -// return testCase{ -// name: "success, with build args", -// remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// tag: "mock-avs-plugin", -// buildArgs: buildArgs, -// setup: func(dockerClient *mocks.MockAPIClient) { -// buildBody := io.NopCloser(bytes.NewReader([]byte{})) -// buildResponse := types.ImageBuildResponse{ -// Body: buildBody, -// } -// loadBody := io.NopCloser(bytes.NewReader([]byte{})) -// loadResponse := types.ImageLoadResponse{ -// Body: loadBody, -// } -// dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ -// RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// Tags: []string{"mock-avs-plugin"}, -// Remove: true, -// ForceRemove: true, -// BuildArgs: buildArgs, -// }).Return(buildResponse, nil) -// dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(loadResponse, nil) -// }, -// expectedError: nil, -// } -// }(t), -// { -// name: "build error", -// remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// tag: "mock-avs-plugin", -// setup: func(dockerClient *mocks.MockAPIClient) { -// dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ -// RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", -// Tags: []string{"mock-avs-plugin"}, -// Remove: true, -// ForceRemove: true, -// }).Return(types.ImageBuildResponse{}, errors.New("build error")) -// }, -// expectedError: errors.New("build error"), -// }, -// { -// name: "load error", -// remote: "https://github.com/orgname/avs#main:plugin", -// tag: "orgname-avs-plugin", -// setup: func(dockerClient *mocks.MockAPIClient) { -// buildBody := io.NopCloser(bytes.NewReader([]byte{})) -// buildResponse := types.ImageBuildResponse{ -// Body: buildBody, -// } -// dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ -// RemoteContext: "https://github.com/orgname/avs#main:plugin", -// Tags: []string{"orgname-avs-plugin"}, -// Remove: true, -// ForceRemove: true, -// }).Return(buildResponse, nil) -// dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(types.ImageLoadResponse{}, errors.New("load error")) -// }, -// expectedError: errors.New("load error"), -// }, -// } +func TestBuildImageFromURI(t *testing.T) { + type testCase struct { + name string + remote string + tag string + buildArgs map[string]*string + setup func(*mocks.MockAPIClient) + expectedError error + } + tests := []testCase{ + { + name: "success", + remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + tag: "mock-avs-plugin", + setup: func(dockerClient *mocks.MockAPIClient) { + buildBody := io.NopCloser(bytes.NewReader([]byte{})) + buildResponse := types.ImageBuildResponse{ + Body: buildBody, + } + loadBody := io.NopCloser(bytes.NewReader([]byte{})) + loadResponse := types.ImageLoadResponse{ + Body: loadBody, + } + dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ + RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + Tags: []string{"mock-avs-plugin"}, + Remove: true, + ForceRemove: true, + }).Return(buildResponse, nil) + dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(loadResponse, nil) + }, + expectedError: nil, + }, + func(t *testing.T) testCase { + buildArgs := map[string]*string{ + "key1": utils.StringPtr("value1"), + "key2": utils.StringPtr("value2"), + } + return testCase{ + name: "success, with build args", + remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + tag: "mock-avs-plugin", + buildArgs: buildArgs, + setup: func(dockerClient *mocks.MockAPIClient) { + buildBody := io.NopCloser(bytes.NewReader([]byte{})) + buildResponse := types.ImageBuildResponse{ + Body: buildBody, + } + loadBody := io.NopCloser(bytes.NewReader([]byte{})) + loadResponse := types.ImageLoadResponse{ + Body: loadBody, + } + dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ + RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + Tags: []string{"mock-avs-plugin"}, + Remove: true, + ForceRemove: true, + BuildArgs: buildArgs, + }).Return(buildResponse, nil) + dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(loadResponse, nil) + }, + expectedError: nil, + } + }(t), + { + name: "build error", + remote: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + tag: "mock-avs-plugin", + setup: func(dockerClient *mocks.MockAPIClient) { + dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ + RemoteContext: "https://github.com/NethermindEth/mock-avs-pkg#main:plugin", + Tags: []string{"mock-avs-plugin"}, + Remove: true, + ForceRemove: true, + }).Return(types.ImageBuildResponse{}, errors.New("build error")) + }, + expectedError: errors.New("build error"), + }, + { + name: "load error", + remote: "https://github.com/orgname/avs#main:plugin", + tag: "orgname-avs-plugin", + setup: func(dockerClient *mocks.MockAPIClient) { + buildBody := io.NopCloser(bytes.NewReader([]byte{})) + buildResponse := types.ImageBuildResponse{ + Body: buildBody, + } + dockerClient.EXPECT().ImageBuild(context.Background(), nil, types.ImageBuildOptions{ + RemoteContext: "https://github.com/orgname/avs#main:plugin", + Tags: []string{"orgname-avs-plugin"}, + Remove: true, + ForceRemove: true, + }).Return(buildResponse, nil) + dockerClient.EXPECT().ImageLoad(context.Background(), buildResponse.Body, true).Return(types.ImageLoadResponse{}, errors.New("load error")) + }, + expectedError: errors.New("load error"), + }, + } -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// ctrl := gomock.NewController(t) -// dockerClient := mocks.NewMockAPIClient(ctrl) -// tt.setup(dockerClient) -// defer ctrl.Finish() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + dockerClient := mocks.NewMockAPIClient(ctrl) + tt.setup(dockerClient) + defer ctrl.Finish() -// dockerManager := NewDockerManager(dockerClient) -// err := dockerManager.BuildImageFromURI(tt.remote, tt.tag, tt.buildArgs) + dockerManager := NewDockerManager(dockerClient) + err := dockerManager.BuildImageFromURI(tt.remote, tt.tag, tt.buildArgs) -// if tt.expectedError != nil { -// assert.EqualError(t, err, tt.expectedError.Error()) -// } else { -// assert.NoError(t, err) -// } -// }) -// } -// } + if tt.expectedError != nil { + assert.EqualError(t, err, tt.expectedError.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} func TestLoadImageContext(t *testing.T) { tests := []struct { @@ -1439,9 +1439,7 @@ func TestDockerManager_Run(t *testing.T) { name string setupMock func(*gomock.Controller) *mocks.MockAPIClient image string - network string - args []string - mounts []Mount + options RunOptions expectedError error }{ { @@ -1467,8 +1465,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, }, { name: "Container create error", @@ -1480,8 +1477,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: errors.New("creation error"), }, { @@ -1507,8 +1503,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: errors.New("remove error"), }, { @@ -1523,8 +1518,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: errors.New("network connection error"), }, { @@ -1546,8 +1540,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: errors.New("start container error"), }, { @@ -1573,8 +1566,7 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: errors.New("error waiting for container containerID: container wait error. container logs: container logs"), }, { @@ -1600,15 +1592,13 @@ func TestDockerManager_Run(t *testing.T) { return dockerClient }, image: "my-image", - network: "my-network", - args: []string{"arg1", "arg2"}, + options: RunOptions{Network: "my-network", Args: []string{"arg1", "arg2"}}, expectedError: fmt.Errorf("unexpected exit code 1 for container containerID. container logs: container logs"), }, { name: "Running on host network", image: "my-image", - network: "host", - args: []string{}, + options: RunOptions{Network: "host", Args: []string{}}, setupMock: func(ctrl *gomock.Controller) *mocks.MockAPIClient { dockerClient := mocks.NewMockAPIClient(ctrl) @@ -1634,20 +1624,22 @@ func TestDockerManager_Run(t *testing.T) { }, }, { - name: "with mounts", - image: "my-image", - network: "my-network", - args: []string{}, - mounts: []Mount{ - { - Type: VolumeTypeBind, - Source: "/home/user/dir", - Target: "/container/dir1", - }, - { - Type: VolumeTypeVolume, - Source: "volume-name", - Target: "/container/dir2", + name: "with mounts", + image: "my-image", + options: RunOptions{ + Network: "my-network", + Args: []string{}, + Mounts: []Mount{ + { + Type: VolumeTypeBind, + Source: "/home/user/dir", + Target: "/container/dir1", + }, + { + Type: VolumeTypeVolume, + Source: "volume-name", + Target: "/container/dir2", + }, }, }, setupMock: func(ctrl *gomock.Controller) *mocks.MockAPIClient { @@ -1699,7 +1691,7 @@ func TestDockerManager_Run(t *testing.T) { dockerManager := NewDockerManager(dockerClient) - err := dockerManager.Run(tt.image, tt.network, tt.args, tt.mounts) + err := dockerManager.Run(tt.image, tt.options) if tt.expectedError != nil { assert.Error(t, err) @@ -1710,3 +1702,66 @@ func TestDockerManager_Run(t *testing.T) { }) } } + +type notFoundError struct{} + +func (e notFoundError) NotFound() {} + +func (e notFoundError) Error() string { + return "not found" +} + +func TestImageExist(t *testing.T) { + image := "test-image:v1.0.0" + + tc := []struct { + name string + ok bool + err error + setup func(*mocks.MockAPIClient) + }{ + { + name: "image exists", + ok: true, + err: nil, + setup: func(dockerClient *mocks.MockAPIClient) { + dockerClient.EXPECT().ImageInspectWithRaw(context.Background(), image).Return(types.ImageInspect{}, nil, nil) + }, + }, + { + name: "image does not exist", + ok: false, + err: nil, + setup: func(dockerClient *mocks.MockAPIClient) { + dockerClient.EXPECT().ImageInspectWithRaw(context.Background(), image).Return(types.ImageInspect{}, nil, notFoundError{}) + }, + }, + { + name: "image inspect error", + ok: false, + err: assert.AnError, + setup: func(dockerClient *mocks.MockAPIClient) { + dockerClient.EXPECT().ImageInspectWithRaw(context.Background(), image).Return(types.ImageInspect{}, nil, assert.AnError) + }, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + dockerClient := mocks.NewMockAPIClient(ctrl) + tt.setup(dockerClient) + defer ctrl.Finish() + dockerManager := NewDockerManager(dockerClient) + + ok, err := dockerManager.ImageExist(image) + + if tt.err != nil { + assert.EqualError(t, err, tt.err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.ok, ok) + }) + } +} diff --git a/internal/utils/ptr.go b/internal/utils/ptr.go new file mode 100644 index 00000000..088ee062 --- /dev/null +++ b/internal/utils/ptr.go @@ -0,0 +1,5 @@ +package utils + +func StringPtr(s string) *string { + return &s +} diff --git a/internal/utils/tar.go b/internal/utils/tar.go index 6b4bffd9..8d7bd067 100644 --- a/internal/utils/tar.go +++ b/internal/utils/tar.go @@ -10,6 +10,14 @@ import ( "path/filepath" log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +const tarBlockSize = 512 + +var ( + ErrTarPrepareToAppend = errors.New("failed preparing to append") + ErrInitializingEmptyTarFile = errors.New("failed initializing empty tar file") ) func CompressToTarGz(srcDir string, tarFile io.Writer) error { @@ -112,3 +120,108 @@ func DecompressTarGz(tarFile io.Reader, destDir string) error { } } } + +// TarInit creates an empty tar file. The tar file is created with 2 empty blocks +// because 2 empty blocks denote the end of the tar file following the specification +// https://www.gnu.org/software/tar/manual/html_node/Standard.html. It is not required +// by the specification, but it is a common practice. +func TarInit(fs afero.Fs, path string) error { + tarFile, err := fs.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer tarFile.Close() + + n, err := tarFile.Write(make([]byte, 2*tarBlockSize)) + if err != nil { + return fmt.Errorf("%w: %s", ErrInitializingEmptyTarFile, err) + } + if n != 2*tarBlockSize { + return fmt.Errorf("%w: %s", ErrInitializingEmptyTarFile, path) + } + return nil +} + +// TarPrepareToAppend prepares a tar file for appending new files. The tar file +// should have 2 empty blocks (1024 bytes) at the end of the file following the +// specification https://www.gnu.org/software/tar/manual/html_node/Standard.html. +// Removes the last 2 blocks (1024 bytes) if they are all 0. Returns an error if +// the tar file is not empty or if the last 1024 bytes are not all 0. +func TarPrepareToAppend(tarFile afero.File) error { + // Prepare tar for append + stats, err := tarFile.Stat() + if err != nil { + return err + } + if stats.Size() == 0 { + return nil + } + if stats.Size() < 2*tarBlockSize { + return fmt.Errorf("%w: tar file is not empty but has less than 2 blocks (1024 bytes)", ErrTarPrepareToAppend) + } + + // Check if the last 1024 bytes are all 0 + d := make([]byte, 1024) + n, err := tarFile.ReadAt(d, stats.Size()-1024) + if err != nil { + return err + } + if n != 1024 { + return fmt.Errorf("%w: read %d bytes instead of 1024", ErrTarPrepareToAppend, n) + } + for _, b := range d { + if b != 0 { + return fmt.Errorf("%w: last 1024 bytes are not all 0", ErrTarPrepareToAppend) + } + } + // Seek last 1024 bytes + _, err = tarFile.Seek(-1024, io.SeekEnd) + return err +} + +// TarAddDir add a directory to a tar file. The directory is added with a prefix +// path +func TarAddDir(srcPath, prefix string, tarFile io.Writer) error { + tarWriter := tar.NewWriter(tarFile) + defer tarWriter.Close() + // walk through every file in the folder + err := filepath.Walk(srcPath, func(file string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + // generate tar header + header, err := tar.FileInfoHeader(fi, file) + if err != nil { + return err + } + + fileRelPath, err := filepath.Rel(srcPath, file) + if err != nil { + return err + } + + header.Name = filepath.Join(prefix, fileRelPath) + + // write header + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + // if not a dir, write file content + if !fi.IsDir() { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(tarWriter, f); err != nil { + return err + } + if err != nil { + return err + } + } + return nil + }) + return err +} diff --git a/internal/utils/tar_test.go b/internal/utils/tar_test.go index d26cad81..0419b046 100644 --- a/internal/utils/tar_test.go +++ b/internal/utils/tar_test.go @@ -1,12 +1,16 @@ package utils import ( + "archive/tar" + "fmt" + "io" "os" "os/exec" "path/filepath" "testing" "github.com/NethermindEth/eigenlayer/internal/common" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -59,6 +63,155 @@ func TestDecompressTarGz(t *testing.T) { assertEqualDirs(t, pkgDir, outDir) } +func TestTarInit(t *testing.T) { + fs := afero.NewMemMapFs() + tarPath := "/init.tar" + + err := TarInit(fs, tarPath) + require.NoError(t, err, "failed to init tar file") + + tarStat, err := fs.Stat(tarPath) + require.NoError(t, err, "failed to stat tar file") + + assert.Equal(t, os.FileMode(0o644), tarStat.Mode(), "tar file has wrong mode") + assert.Equal(t, int64(2*tarBlockSize), tarStat.Size(), "tar file has wrong size") +} + +func TestTarPrepareToAppend(t *testing.T) { + tc := []struct { + name string + tarFile func(*testing.T) afero.File + err error + }{ + { + name: "empty tar file", + tarFile: func(t *testing.T) afero.File { + fs := afero.NewMemMapFs() + tarPath := "/empty.tar" + + tarFile, err := fs.Create(tarPath) + require.NoError(t, err, "failed to create tar file") + + d := make([]byte, 1024) + _, err = tarFile.Write(d) + require.NoError(t, err, "failed to write to tar file") + err = tarFile.Close() + require.NoError(t, err, "failed to close tar file") + + tarFile, err = fs.Open(tarPath) + require.NoError(t, err, "failed to open tar file") + return tarFile + }, + }, + { + name: "error, tar file with less than 2 blocks (1024 bytes)", + tarFile: func(t *testing.T) afero.File { + fs := afero.NewMemMapFs() + tarPath := "/empty.tar" + + tarFile, err := fs.Create(tarPath) + require.NoError(t, err, "failed to create tar file") + + d := make([]byte, 1000) + _, err = tarFile.Write(d) + require.NoError(t, err, "failed to write to tar file") + err = tarFile.Close() + require.NoError(t, err, "failed to close tar file") + + tarFile, err = fs.Open(tarPath) + require.NoError(t, err, "failed to open tar file") + return tarFile + }, + err: fmt.Errorf("%w: tar file is not empty but has less than 2 blocks (1024 bytes)", ErrTarPrepareToAppend), + }, + { + name: "error, tar file with 2 blocks (1024 bytes) but not zeroed", + tarFile: func(t *testing.T) afero.File { + fs := afero.NewMemMapFs() + tarPath := "/empty.tar" + + tarFile, err := fs.Create(tarPath) + require.NoError(t, err, "failed to create tar file") + + d := make([]byte, 1024) + d[100] = 1 + _, err = tarFile.Write(d) + require.NoError(t, err, "failed to write to tar file") + err = tarFile.Close() + require.NoError(t, err, "failed to close tar file") + + tarFile, err = fs.Open(tarPath) + require.NoError(t, err, "failed to open tar file") + return tarFile + }, + err: fmt.Errorf("%w: last 1024 bytes are not all 0", ErrTarPrepareToAppend), + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + tarFile := tt.tarFile(t) + defer tarFile.Close() + + err := TarPrepareToAppend(tarFile) + if tt.err == nil { + assert.NoError(t, err, "unexpected error") + } else { + require.Error(t, err, "expected error") + assert.EqualError(t, err, tt.err.Error(), "not equal errors") + } + }) + } +} + +func TestTarAddDir(t *testing.T) { + testDir := t.TempDir() + srcPath := filepath.Join(testDir, "src") + err := os.MkdirAll(srcPath, 0o755) + require.NoError(t, err, "failed to create src dir") + + // create a file in the src directory + fileContent := []byte("test file content") + filePath := filepath.Join(srcPath, "test.txt") + file, err := os.Create(filePath) + require.NoError(t, err, "failed to create test file") + n, err := file.Write(fileContent) + require.NoError(t, err, "failed to write to test file") + require.Equal(t, len(fileContent), n, "failed to write all data to test file") + + // create a tar file and add the src directory to it + tarPath := filepath.Join(testDir, "test.tar") + tarFile, err := os.Create(tarPath) + require.NoError(t, err, "failed to create tar file") + defer tarFile.Close() + + err = TarAddDir(srcPath, "prefix/path", tarFile) + require.NoError(t, err, "failed to add src dir to tar file") + + // read the tar file and check its contents + tarFile, err = os.Open(tarPath) + require.NoError(t, err, "failed to open tar file") + defer tarFile.Close() + + tarReader := tar.NewReader(tarFile) + // Root header + header, err := tarReader.Next() + require.NoError(t, err, "failed to read tar header") + assert.Equal(t, "prefix/path", header.Name, "root header has wrong name") + // Test file header + header, err = tarReader.Next() + require.NoError(t, err, "failed to read tar header") + assert.Equal(t, "prefix/path/test.txt", header.Name, "test.txt header has wrong name") + assert.Equal(t, int64(len(fileContent)), header.Size, "test.txt header has wrong size") + // Test file content + content := make([]byte, len(fileContent)) + n, err = tarReader.Read(content) + assert.Equal(t, n, len(fileContent), "failed to read all data from test.txt") + require.ErrorIs(t, err, io.EOF, "expected EOF") + // Test EOF + _, err = tarReader.Next() + require.Equal(t, io.EOF, err, "expected EOF") +} + func assertEqualDirs(t *testing.T, dir1, dir2 string) { err := filepath.Walk(dir1, func(path1 string, info1 os.FileInfo, err1 error) error { if err1 != nil { diff --git a/pkg/daemon/backup.go b/pkg/daemon/backup.go new file mode 100644 index 00000000..6aaf5afa --- /dev/null +++ b/pkg/daemon/backup.go @@ -0,0 +1,10 @@ +package daemon + +import "github.com/NethermindEth/eigenlayer/internal/backup" + +type BackupManager interface { + // BackupInstance creates a backup of the instance with the given ID. + BackupInstance(instanceId string) (string, error) + // BackupList returns a list of all backups. + BackupList() ([]backup.BackupInfo, error) +} diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 28c8f8c8..c8ef3de9 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" ) // Daemon is the interface for the egn daemon. It should be used as the entrypoint @@ -70,6 +71,14 @@ type Daemon interface { // NodeLogs returns the logs of the node with the given ID. If there is no // installed instance with the given ID an error will be returned. NodeLogs(ctx context.Context, w io.Writer, instanceID string, opts NodeLogsOptions) error + + // Backup creates a backup of the instance with the given ID and returns the + // backup ID. If there is no installed instance with the given ID an error + // will be returned. + Backup(instanceId string) (backupId string, err error) + + // BackupList returns a list of all the backups and their information. + BackupList() ([]BackupInfo, error) } type PullTarget struct { @@ -246,3 +255,9 @@ type HardwareRequirements struct { func (h HardwareRequirements) String() string { return fmt.Sprintf("CPU: %d Cores, RAM: %d Mb, Disk Space: %d Mb", h.MinCPUCores, h.MinRAM, h.MinFreeSpace) } + +type BackupInfo struct { + Instance string + Timestamp time.Time + SizeBytes uint64 +} diff --git a/pkg/daemon/docker.go b/pkg/daemon/docker.go index 50eec5f5..3e062681 100644 --- a/pkg/daemon/docker.go +++ b/pkg/daemon/docker.go @@ -21,7 +21,7 @@ type DockerManager interface { LoadImageContext(path string) (io.ReadCloser, error) // Run runs the given image with the given network and arguments. - Run(image string, network string, args []string, mounts []docker.Mount) error + Run(image string, options docker.RunOptions) error // ContainerLogsMerged returns the merge of the logs of the given services. ContainerLogsMerged(ctx context.Context, w io.Writer, services map[string]string, opts docker.ContainerLogsMergedOptions) error diff --git a/pkg/daemon/egn_daemon.go b/pkg/daemon/egn_daemon.go index 66043798..cf964fd3 100644 --- a/pkg/daemon/egn_daemon.go +++ b/pkg/daemon/egn_daemon.go @@ -41,6 +41,7 @@ type EgnDaemon struct { docker DockerManager monitoringMgr MonitoringManager locker locker.Locker + backupManager BackupManager } // NewDaemon create a new daemon instance. @@ -49,6 +50,7 @@ func NewEgnDaemon( cmpMgr ComposeManager, dockerMgr DockerManager, mtrMgr MonitoringManager, + backupMgr BackupManager, locker locker.Locker, ) (*EgnDaemon, error) { return &EgnDaemon{ @@ -57,6 +59,7 @@ func NewEgnDaemon( docker: dockerMgr, monitoringMgr: mtrMgr, locker: locker, + backupManager: backupMgr, }, nil } @@ -998,7 +1001,11 @@ func (d *EgnDaemon) RunPlugin(instanceId string, pluginArgs []string, options Ru Target: dst, }) } - return d.docker.Run(instance.Plugin.Image, network, pluginArgs, mounts) + return d.docker.Run(instance.Plugin.Image, docker.RunOptions{ + Network: network, + Args: pluginArgs, + Mounts: mounts, + }) } // NodeLogs implements Daemon.NodeLogs. @@ -1029,6 +1036,26 @@ func (d *EgnDaemon) NodeLogs(ctx context.Context, w io.Writer, instanceID string }) } +func (d *EgnDaemon) Backup(instanceId string) (string, error) { + return d.backupManager.BackupInstance(instanceId) +} + +func (d *EgnDaemon) BackupList() ([]BackupInfo, error) { + backupInfo, err := d.backupManager.BackupList() + if err != nil { + return nil, err + } + out := make([]BackupInfo, len(backupInfo)) + for i, b := range backupInfo { + out[i] = BackupInfo{ + Instance: b.Instance, + Timestamp: b.Timestamp, + SizeBytes: b.SizeBytes, + } + } + return out, nil +} + func tempID(url string) string { tempHash := sha256.Sum256([]byte(url)) return hex.EncodeToString(tempHash[:]) diff --git a/pkg/daemon/egn_daemon_test.go b/pkg/daemon/egn_daemon_test.go index 89b526c0..b34b3861 100644 --- a/pkg/daemon/egn_daemon_test.go +++ b/pkg/daemon/egn_daemon_test.go @@ -379,6 +379,9 @@ func TestInitMonitoring(t *testing.T) { // Create a mock locker locker := mock_locker.NewMockLocker(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) + // Create in-memory filesystem afs := afero.NewMemMapFs() @@ -390,7 +393,7 @@ func TestInitMonitoring(t *testing.T) { monitoringMgr := tt.mocker(t, ctrl) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeMgr, dockerMgr, monitoringMgr, locker) + daemon, err := NewEgnDaemon(dataDir, composeMgr, dockerMgr, monitoringMgr, backupMgr, locker) require.NoError(t, err) err = daemon.InitMonitoring(true, true) @@ -465,6 +468,9 @@ func TestCleanMonitoring(t *testing.T) { // Create mock docker manager dockerMgr := mocks.NewMockDockerManager(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) + // Create a mock locker locker := mock_locker.NewMockLocker(ctrl) @@ -479,7 +485,7 @@ func TestCleanMonitoring(t *testing.T) { monitoringMgr := tt.mocker(t, ctrl) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeMgr, dockerMgr, monitoringMgr, locker) + daemon, err := NewEgnDaemon(dataDir, composeMgr, dockerMgr, monitoringMgr, backupMgr, locker) require.NoError(t, err) err = daemon.CleanMonitoring() @@ -589,7 +595,7 @@ func TestPull(t *testing.T) { dataDir := tt.mocker(t, locker) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, nil, nil, nil, locker) + daemon, err := NewEgnDaemon(dataDir, nil, nil, nil, nil, locker) require.NoError(t, err) result, err := daemon.Pull(tt.url, tt.ref, tt.force) @@ -1059,7 +1065,7 @@ func TestInstall(t *testing.T) { tt.mocker(tmp, composeManager, dockerManager, locker, monitoringManager) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, nil, locker) require.NoError(t, err) // Pull the package @@ -1160,12 +1166,12 @@ func TestRun(t *testing.T) { mocker: func(tmp string, composeManager *mocks.MockComposeManager, dockerManager *mocks.MockDockerManager, locker *mock_locker.MockLocker, monitoringManager *mocks.MockMonitoringManager) { path := filepath.Join(tmp, "nodes", instanceID, "docker-compose.yml") + locker.EXPECT().New(filepath.Join(tmp, "nodes", instanceID, ".lock")).Return(locker).Times(2) + locker.EXPECT().Lock().Return(nil) + locker.EXPECT().Locked().Return(true) + locker.EXPECT().Unlock().Return(nil) // Init, install and run gomock.InOrder( - locker.EXPECT().New(filepath.Join(tmp, "nodes", instanceID, ".lock")).Return(locker), - locker.EXPECT().Lock().Return(nil), - locker.EXPECT().Locked().Return(true), - locker.EXPECT().Unlock().Return(nil), composeManager.EXPECT().Create(compose.DockerComposeCreateOptions{Path: path, Build: true}).Return(nil), composeManager.EXPECT().Up(compose.DockerComposeUpOptions{Path: path}).Return(nil), monitoringManager.EXPECT().InstallationStatus().Return(common.Installed, nil), @@ -1205,12 +1211,12 @@ func TestRun(t *testing.T) { mocker: func(tmp string, composeManager *mocks.MockComposeManager, dockerManager *mocks.MockDockerManager, locker *mock_locker.MockLocker, monitoringManager *mocks.MockMonitoringManager) { path := filepath.Join(tmp, "nodes", instanceID, "docker-compose.yml") + locker.EXPECT().New(filepath.Join(tmp, "nodes", instanceID, ".lock")).Return(locker).Times(2) + locker.EXPECT().Lock().Return(nil) + locker.EXPECT().Locked().Return(true) + locker.EXPECT().Unlock().Return(nil) // Init, install and run gomock.InOrder( - locker.EXPECT().New(filepath.Join(tmp, "nodes", instanceID, ".lock")).Return(locker), - locker.EXPECT().Lock().Return(nil), - locker.EXPECT().Locked().Return(true), - locker.EXPECT().Unlock().Return(nil), composeManager.EXPECT().Create(compose.DockerComposeCreateOptions{Path: path, Build: true}).Return(nil), composeManager.EXPECT().Up(compose.DockerComposeUpOptions{Path: path}).Return(nil), monitoringManager.EXPECT().InstallationStatus().Return(common.Installed, nil), @@ -1350,6 +1356,8 @@ func TestRun(t *testing.T) { locker := mock_locker.NewMockLocker(ctrl) // Create a mock monitoring manager monitoringManager := mocks.NewMockMonitoringManager(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) // Create a Datadir dataDir, err := data.NewDataDir(tmp, afs, locker) @@ -1358,7 +1366,7 @@ func TestRun(t *testing.T) { tt.mocker(tmp, composeManager, dockerManager, locker, monitoringManager) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) if tt.options != nil { @@ -1471,6 +1479,8 @@ func TestStop(t *testing.T) { locker := mock_locker.NewMockLocker(ctrl) // Create a mock monitoring manager monitoringManager := mocks.NewMockMonitoringManager(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) // Create a Datadir dataDir, err := data.NewDataDir(tmp, afs, locker) @@ -1479,7 +1489,7 @@ func TestStop(t *testing.T) { tt.mocker(tmp, composeManager, dockerManager, locker, monitoringManager) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) if tt.options != nil { @@ -1650,6 +1660,8 @@ func TestUninstall(t *testing.T) { locker := mock_locker.NewMockLocker(ctrl) // Create a mock monitoring manager monitoringManager := mocks.NewMockMonitoringManager(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) // Create a Datadir dataDir, err := data.NewDataDir(tmp, afs, locker) @@ -1658,7 +1670,7 @@ func TestUninstall(t *testing.T) { tt.mocker(tmp, composeManager, dockerManager, locker, monitoringManager) // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) if tt.options != nil { @@ -1734,6 +1746,7 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -1846,6 +1859,7 @@ func TestListInstances(t *testing.T) { var mockCalls []*gomock.Call for _, instance := range instances { initInstanceDir(t, d.fs, d.dataDirPath, instance.id, instance.stateJSON) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", instance.id, ".lock")).Return(d.locker).Times(3) mockCalls = append(mockCalls, d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ Path: filepath.Join(d.dataDirPath, "nodes", instance.id, "docker-compose.yml"), @@ -1981,6 +1995,9 @@ func TestListInstances(t *testing.T) { }(), } + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-0", ".lock")).Return(d.locker).Times(3) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-1", ".lock")).Return(d.locker).Times(2) + var mockCalls []*gomock.Call for _, instance := range instances { initInstanceDir(t, d.fs, d.dataDirPath, instance.id, instance.stateJSON) @@ -2085,6 +2102,9 @@ func TestListInstances(t *testing.T) { }, } + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-0", ".lock")).Return(d.locker).Times(3) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-1", ".lock")).Return(d.locker).Times(3) + var mockCalls []*gomock.Call for _, instance := range instances { initInstanceDir(t, d.fs, d.dataDirPath, instance.id, instance.stateJSON) @@ -2152,6 +2172,9 @@ func TestListInstances(t *testing.T) { }, } + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-0", ".lock")).Return(d.locker).Times(2) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-1", ".lock")).Return(d.locker).Times(2) + var mockCalls []*gomock.Call for _, instance := range instances { initInstanceDir(t, d.fs, d.dataDirPath, instance.id, instance.stateJSON) @@ -2201,6 +2224,7 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2257,6 +2281,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2319,6 +2345,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(2) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2358,6 +2386,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2414,6 +2444,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2470,6 +2502,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2526,6 +2560,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(2) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2565,6 +2601,8 @@ func TestListInstances(t *testing.T) { } }`) + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker).Times(3) + // Mocks gomock.InOrder( d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ @@ -2611,6 +2649,7 @@ func TestListInstances(t *testing.T) { dockerManager := mocks.NewMockDockerManager(ctrl) locker := mock_locker.NewMockLocker(ctrl) monitoringManager := mocks.NewMockMonitoringManager(ctrl) + backupMgr := mocks.NewMockBackupManager(ctrl) tmp, err := afero.TempDir(afs, "", "egn-test-install") require.NoError(t, err) @@ -2631,7 +2670,7 @@ func TestListInstances(t *testing.T) { } // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) // List instances @@ -2709,20 +2748,23 @@ func TestNodeLogs(t *testing.T) { "profile": "option-returner", "url": "`+common.MockAvsPkg.Repo()+`" }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - Path: filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - All: true, - }).Return([]compose.ComposeService{ - { - Id: "abc123", - Name: "main-service", - State: "running", - }, - }, nil) - d.dockerManager.EXPECT().ContainerLogsMerged(context.Background(), w, map[string]string{ - "main-service": "abc123", - }, docker.ContainerLogsMergedOptions{}) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + Path: filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + All: true, + }).Return([]compose.ComposeService{ + { + Id: "abc123", + Name: "main-service", + State: "running", + }, + }, nil), + d.dockerManager.EXPECT().ContainerLogsMerged(context.Background(), w, map[string]string{ + "main-service": "abc123", + }, docker.ContainerLogsMergedOptions{}), + ) }, }, { @@ -2742,11 +2784,14 @@ func TestNodeLogs(t *testing.T) { "profile": "option-returner", "url": "`+common.MockAvsPkg.Repo()+`" }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - Path: filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - All: true, - }).Return([]compose.ComposeService{}, assert.AnError) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + Path: filepath.Join(d.dataDirPath, "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + All: true, + }).Return([]compose.ComposeService{}, assert.AnError), + ) }, }, } @@ -2758,6 +2803,7 @@ func TestNodeLogs(t *testing.T) { dockerManager := mocks.NewMockDockerManager(ctrl) locker := mock_locker.NewMockLocker(ctrl) monitoringManager := mocks.NewMockMonitoringManager(ctrl) + backupMgr := mocks.NewMockBackupManager(ctrl) tmp, err := afero.TempDir(afs, "", "egn-test-install") require.NoError(t, err) @@ -2778,7 +2824,7 @@ func TestNodeLogs(t *testing.T) { } // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) err = daemon.NodeLogs(tt.ctx, tt.w, tt.instanceID, tt.opts) @@ -2834,6 +2880,7 @@ func TestRunPlugin(t *testing.T) { } }`) gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ FilterRunning: true, Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), @@ -2844,16 +2891,20 @@ func TestRunPlugin(t *testing.T) { }, }, nil), d.dockerManager.EXPECT().ContainerNetworks("abc123").Return([]string{"network-el"}, nil), - d.dockerManager.EXPECT().Run(common.PluginImage.FullImage(), "network-el", []string{"arg1", "arg2"}, []docker.Mount{ - { - Type: docker.VolumeTypeBind, - Source: "/tmp", - Target: "/tmp", - }, - { - Type: docker.VolumeTypeVolume, - Source: "volume1", - Target: "/tmp/volume1", + d.dockerManager.EXPECT().Run(common.PluginImage.FullImage(), docker.RunOptions{ + Network: "network-el", + Args: []string{"arg1", "arg2"}, + Mounts: []docker.Mount{ + { + Type: docker.VolumeTypeBind, + Source: "/tmp", + Target: "/tmp", + }, + { + Type: docker.VolumeTypeVolume, + Source: "volume1", + Target: "/tmp/volume1", + }, }, }), d.dockerManager.EXPECT().ImageRemove(common.PluginImage.FullImage()).Return(nil), @@ -2885,16 +2936,21 @@ func TestRunPlugin(t *testing.T) { } }`) gomock.InOrder( - d.dockerManager.EXPECT().Run(common.PluginImage.FullImage(), docker.NetworkHost, []string{"arg1", "arg2"}, []docker.Mount{ - { - Type: docker.VolumeTypeBind, - Source: "/tmp", - Target: "/tmp", - }, - { - Type: docker.VolumeTypeVolume, - Source: "volume1", - Target: "/tmp/volume1", + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.dockerManager.EXPECT().Run(common.PluginImage.FullImage(), docker.RunOptions{ + Network: docker.NetworkHost, + Args: []string{"arg1", "arg2"}, + Mounts: []docker.Mount{ + { + Type: docker.VolumeTypeBind, + Source: "/tmp", + Target: "/tmp", + }, + { + Type: docker.VolumeTypeVolume, + Source: "volume1", + Target: "/tmp/volume1", + }, }, }), d.dockerManager.EXPECT().ImageRemove(common.PluginImage.FullImage()).Return(nil), @@ -2918,6 +2974,7 @@ func TestRunPlugin(t *testing.T) { "profile": "option-returner", "url": "`+common.MockAvsPkg.Repo()+`" }`) + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker) }, }, { @@ -2935,11 +2992,14 @@ func TestRunPlugin(t *testing.T) { "image": "`+common.PluginImage.FullImage()+`" } }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - FilterRunning: true, - Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - }).Return([]compose.ComposeService{}, assert.AnError) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + FilterRunning: true, + Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + }).Return([]compose.ComposeService{}, assert.AnError), + ) }, }, { @@ -2957,11 +3017,14 @@ func TestRunPlugin(t *testing.T) { "image": "`+common.PluginImage.FullImage()+`" } }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - FilterRunning: true, - Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - }).Return([]compose.ComposeService{}, nil) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + FilterRunning: true, + Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + }).Return([]compose.ComposeService{}, nil), + ) }, }, { @@ -2979,16 +3042,15 @@ func TestRunPlugin(t *testing.T) { "image": "`+common.PluginImage.FullImage()+`" } }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - FilterRunning: true, - Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - }).Return([]compose.ComposeService{ - { - Id: "abc123", - }, - }, nil) - d.dockerManager.EXPECT().ContainerNetworks("abc123").Return(nil, assert.AnError) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + FilterRunning: true, + Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + }).Return([]compose.ComposeService{{Id: "abc123"}}, nil), + d.dockerManager.EXPECT().ContainerNetworks("abc123").Return(nil, assert.AnError), + ) }, }, { @@ -3006,16 +3068,15 @@ func TestRunPlugin(t *testing.T) { "image": "`+common.PluginImage.FullImage()+`" } }`) - d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ - FilterRunning: true, - Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), - Format: "json", - }).Return([]compose.ComposeService{ - { - Id: "abc123", - }, - }, nil) - d.dockerManager.EXPECT().ContainerNetworks("abc123").Return([]string{}, nil) + gomock.InOrder( + d.locker.EXPECT().New(filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", ".lock")).Return(d.locker), + d.composeManager.EXPECT().PS(compose.DockerComposePsOptions{ + FilterRunning: true, + Path: filepath.Join(d.dataDir.Path(), "nodes", "mock-avs-default", "docker-compose.yml"), + Format: "json", + }).Return([]compose.ComposeService{{Id: "abc123"}}, nil), + d.dockerManager.EXPECT().ContainerNetworks("abc123").Return([]string{}, nil), + ) }, }, } @@ -3027,6 +3088,8 @@ func TestRunPlugin(t *testing.T) { dockerManager := mocks.NewMockDockerManager(ctrl) locker := mock_locker.NewMockLocker(ctrl) monitoringManager := mocks.NewMockMonitoringManager(ctrl) + // Create mock backup manager + backupMgr := mocks.NewMockBackupManager(ctrl) tmp, err := afero.TempDir(afs, "", "egn-test-install") require.NoError(t, err) @@ -3047,7 +3110,7 @@ func TestRunPlugin(t *testing.T) { } // Create a daemon - daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, locker) + daemon, err := NewEgnDaemon(dataDir, composeManager, dockerManager, monitoringManager, backupMgr, locker) require.NoError(t, err) err = daemon.RunPlugin(tt.instanceId, tt.args, tt.options) @@ -3092,7 +3155,7 @@ func TestGetPluginData(t *testing.T) { } defer dockerClient.Close() dockerManager := docker.NewDockerManager(dockerClient) - daemon, err := NewEgnDaemon(dataDir, nil, dockerManager, nil, lock) + daemon, err := NewEgnDaemon(dataDir, nil, dockerManager, nil, nil, lock) require.NoError(t, err, "failed to initialize daemon") // Tests diff --git a/pkg/daemon/gen.go b/pkg/daemon/gen.go index b94cdcc6..83d906d2 100644 --- a/pkg/daemon/gen.go +++ b/pkg/daemon/gen.go @@ -3,3 +3,4 @@ package daemon //go:generate mockgen -destination=./mocks/monitoring_manager.go -package=mocks github.com/NethermindEth/eigenlayer/pkg/daemon MonitoringManager //go:generate mockgen -destination=./mocks/compose.go -package=mocks github.com/NethermindEth/eigenlayer/pkg/daemon ComposeManager //go:generate mockgen -destination=./mocks/docker.go -package=mocks github.com/NethermindEth/eigenlayer/pkg/daemon DockerManager +//go:generate mockgen -destination=./mocks/backup.go -package=mocks github.com/NethermindEth/eigenlayer/pkg/daemon BackupManager